google docs + frontend

This commit is contained in:
Daniel O'Connell 2025-12-29 14:59:37 +01:00
parent a238ca6329
commit 849f03877a
27 changed files with 6070 additions and 8 deletions

View File

@ -0,0 +1,187 @@
"""Add Google Drive integration tables
Revision ID: e1f2a3b4c5d6
Revises: d0e1f2a3b4c5
Create Date: 2025-12-29 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "e1f2a3b4c5d6"
down_revision: Union[str, None] = "d0e1f2a3b4c5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create google_oauth_config table (for storing OAuth credentials)
op.create_table(
"google_oauth_config",
sa.Column("id", sa.BigInteger(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("client_id", sa.Text(), nullable=False),
sa.Column("client_secret", sa.Text(), nullable=False),
sa.Column("project_id", sa.Text(), nullable=True),
sa.Column(
"auth_uri",
sa.Text(),
server_default="https://accounts.google.com/o/oauth2/auth",
nullable=False,
),
sa.Column(
"token_uri",
sa.Text(),
server_default="https://oauth2.googleapis.com/token",
nullable=False,
),
sa.Column(
"redirect_uris", sa.ARRAY(sa.Text()), server_default="{}", nullable=False
),
sa.Column(
"javascript_origins", sa.ARRAY(sa.Text()), server_default="{}", nullable=False
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
# Create google_accounts table
op.create_table(
"google_accounts",
sa.Column("id", sa.BigInteger(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("email", sa.Text(), nullable=False),
sa.Column("access_token", sa.Text(), nullable=True),
sa.Column("refresh_token", sa.Text(), nullable=True),
sa.Column("token_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("scopes", sa.ARRAY(sa.Text()), server_default="{}", nullable=False),
sa.Column("active", sa.Boolean(), server_default="true", nullable=False),
sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("sync_error", sa.Text(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
)
op.create_index(
"google_accounts_active_idx", "google_accounts", ["active", "last_sync_at"]
)
op.create_index("google_accounts_email_idx", "google_accounts", ["email"])
# Create google_folders table
op.create_table(
"google_folders",
sa.Column("id", sa.BigInteger(), nullable=False),
sa.Column("account_id", sa.BigInteger(), nullable=False),
sa.Column("folder_id", sa.Text(), nullable=False),
sa.Column("folder_name", sa.Text(), nullable=False),
sa.Column("folder_path", sa.Text(), nullable=True),
sa.Column("recursive", sa.Boolean(), server_default="true", nullable=False),
sa.Column(
"include_shared", sa.Boolean(), server_default="false", nullable=False
),
sa.Column(
"mime_type_filter", sa.ARRAY(sa.Text()), server_default="{}", nullable=False
),
sa.Column("tags", sa.ARRAY(sa.Text()), server_default="{}", nullable=False),
sa.Column("check_interval", sa.Integer(), server_default="60", nullable=False),
sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("active", sa.Boolean(), server_default="true", nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["account_id"], ["google_accounts.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"account_id", "folder_id", name="unique_folder_per_account"
),
)
op.create_index(
"google_folders_active_idx", "google_folders", ["active", "last_sync_at"]
)
# Create google_doc table (inherits from source_item)
op.create_table(
"google_doc",
sa.Column("id", sa.BigInteger(), nullable=False),
sa.Column("google_file_id", sa.Text(), nullable=False),
sa.Column("google_modified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("title", sa.Text(), nullable=False),
sa.Column("original_mime_type", sa.Text(), nullable=True),
sa.Column("folder_id", sa.BigInteger(), nullable=True),
sa.Column("folder_path", sa.Text(), nullable=True),
sa.Column("owner", sa.Text(), nullable=True),
sa.Column("last_modified_by", sa.Text(), nullable=True),
sa.Column("word_count", sa.Integer(), nullable=True),
sa.Column("content_hash", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(["id"], ["source_item.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["folder_id"], ["google_folders.id"], ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"google_doc_file_id_idx", "google_doc", ["google_file_id"], unique=True
)
op.create_index("google_doc_folder_idx", "google_doc", ["folder_id"])
op.create_index("google_doc_modified_idx", "google_doc", ["google_modified_at"])
op.create_index("google_doc_title_idx", "google_doc", ["title"])
def downgrade() -> None:
# Drop google_doc table
op.drop_index("google_doc_title_idx", table_name="google_doc")
op.drop_index("google_doc_modified_idx", table_name="google_doc")
op.drop_index("google_doc_folder_idx", table_name="google_doc")
op.drop_index("google_doc_file_id_idx", table_name="google_doc")
op.drop_table("google_doc")
# Drop google_folders table
op.drop_index("google_folders_active_idx", table_name="google_folders")
op.drop_table("google_folders")
# Drop google_accounts table
op.drop_index("google_accounts_email_idx", table_name="google_accounts")
op.drop_index("google_accounts_active_idx", table_name="google_accounts")
op.drop_table("google_accounts")
# Drop google_oauth_config table
op.drop_table("google_oauth_config")

View File

@ -959,4 +959,980 @@ body {
align-items: stretch;
gap: 0.5rem;
}
}
/* ============================================
Sources Management Styles
============================================ */
.sources-view {
min-height: 100vh;
background: #f8fafc;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.sources-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e2e8f0;
}
.sources-header h2 {
color: #2d3748;
font-size: 1.8rem;
font-weight: 600;
}
/* Tabs */
.sources-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
border-bottom: 2px solid #e2e8f0;
padding-bottom: 0;
}
.sources-tabs .tab {
background: none;
border: none;
padding: 0.75rem 1.5rem;
font-size: 1rem;
color: #718096;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.2s;
}
.sources-tabs .tab:hover {
color: #4a5568;
}
.sources-tabs .tab.active {
color: #667eea;
border-bottom-color: #667eea;
font-weight: 500;
}
/* Source Panel */
.source-panel {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.panel-header h3 {
color: #2d3748;
font-size: 1.2rem;
font-weight: 600;
}
/* Source List */
.source-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Source Card */
.source-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 1rem;
transition: border-color 0.2s;
}
.source-card:hover {
border-color: #cbd5e0;
}
.source-card.inactive {
opacity: 0.7;
background: #f1f5f9;
}
.source-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.source-card-info h4 {
color: #2d3748;
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.source-subtitle {
color: #718096;
font-size: 0.85rem;
}
.source-details {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin: 0.5rem 0;
font-size: 0.85rem;
color: #4a5568;
}
.source-description {
color: #4a5568;
font-size: 0.9rem;
margin: 0.5rem 0;
}
.source-card-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid #e2e8f0;
}
.source-card-actions-inline {
display: flex;
gap: 0.5rem;
align-items: center;
}
/* Status Badge */
.status-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.status-badge.active {
background: #c6f6d5;
color: #276749;
}
.status-badge.inactive {
background: #fed7d7;
color: #c53030;
}
/* Sync Status */
.sync-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: #718096;
}
.sync-error {
color: #c53030;
font-weight: 500;
}
/* Buttons */
.add-btn {
background: #667eea;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s;
}
.add-btn:hover {
background: #5a67d8;
}
.add-btn.small {
padding: 0.25rem 0.75rem;
font-size: 0.8rem;
}
.sync-btn {
background: #48bb78;
color: white;
border: none;
padding: 0.375rem 0.75rem;
border-radius: 4px;
font-size: 0.85rem;
cursor: pointer;
transition: background-color 0.2s;
}
.sync-btn:hover:not(:disabled) {
background: #38a169;
}
.sync-btn:disabled {
background: #a0aec0;
cursor: not-allowed;
}
.edit-btn {
background: #edf2f7;
color: #4a5568;
border: 1px solid #e2e8f0;
padding: 0.375rem 0.75rem;
border-radius: 4px;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
}
.edit-btn:hover {
background: #e2e8f0;
}
.delete-btn {
background: #fff5f5;
color: #c53030;
border: 1px solid #fed7d7;
padding: 0.375rem 0.75rem;
border-radius: 4px;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
}
.delete-btn:hover {
background: #fed7d7;
}
.delete-btn.small {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.toggle-btn {
background: #edf2f7;
color: #4a5568;
border: 1px solid #e2e8f0;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
cursor: pointer;
}
.toggle-btn:hover {
background: #e2e8f0;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem;
color: #718096;
}
.empty-state p {
margin-bottom: 1rem;
}
/* Loading State */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: #718096;
}
.loading-state .loading-spinner {
margin-bottom: 1rem;
}
/* Error State */
.error-state {
text-align: center;
padding: 2rem;
color: #c53030;
background: #fff5f5;
border-radius: 8px;
}
.retry-btn {
background: #667eea;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
margin-top: 1rem;
cursor: pointer;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 12px;
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid #e2e8f0;
}
.modal-header h3 {
color: #2d3748;
font-size: 1.1rem;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
color: #718096;
cursor: pointer;
line-height: 1;
}
.modal-close:hover {
color: #4a5568;
}
.modal-content {
padding: 1.5rem;
}
/* Form Styles */
.source-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.form-group label {
font-size: 0.9rem;
font-weight: 500;
color: #4a5568;
}
.form-group input,
.form-group select,
.form-group textarea {
padding: 0.5rem 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 0.9rem;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
}
.form-group.checkbox label {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.form-group.checkboxes {
flex-direction: row;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.9rem;
color: #4a5568;
cursor: pointer;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-hint {
font-size: 0.75rem;
color: #718096;
margin-top: 0.25rem;
}
.form-error {
background: #fff5f5;
color: #c53030;
padding: 0.75rem;
border-radius: 6px;
font-size: 0.9rem;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 0.5rem;
padding-top: 1rem;
border-top: 1px solid #e2e8f0;
}
.cancel-btn {
background: #edf2f7;
color: #4a5568;
border: 1px solid #e2e8f0;
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
}
.cancel-btn:hover {
background: #e2e8f0;
}
.submit-btn {
background: #667eea;
color: white;
border: none;
padding: 0.5rem 1.5rem;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
}
.submit-btn:hover:not(:disabled) {
background: #5a67d8;
}
.submit-btn:disabled {
background: #a0aec0;
cursor: not-allowed;
}
/* Tags Input */
.tags-input {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.tags-input .tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: #edf2f7;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
}
.tags-input .tag button {
background: none;
border: none;
color: #718096;
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0;
}
.tags-input .tag button:hover {
color: #c53030;
}
.tag-input {
padding: 0.375rem 0.5rem;
border: 1px solid #e2e8f0;
border-radius: 4px;
font-size: 0.85rem;
}
/* Interval Input */
.interval-input {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.interval-input label {
font-size: 0.9rem;
font-weight: 500;
color: #4a5568;
}
.interval-controls {
display: flex;
align-items: center;
gap: 0.375rem;
}
.interval-controls input {
width: 60px;
padding: 0.375rem 0.5rem;
border: 1px solid #e2e8f0;
border-radius: 4px;
font-size: 0.9rem;
text-align: center;
}
.interval-controls span {
color: #718096;
font-size: 0.85rem;
}
/* Confirm Dialog */
.confirm-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1001;
}
.confirm-dialog {
background: white;
padding: 1.5rem;
border-radius: 8px;
max-width: 400px;
text-align: center;
}
.confirm-dialog p {
margin-bottom: 1rem;
color: #4a5568;
}
.confirm-dialog-buttons {
display: flex;
justify-content: center;
gap: 0.75rem;
}
.confirm-btn {
background: #e53e3e;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
}
.confirm-btn:hover {
background: #c53030;
}
/* GitHub Account Card */
.github-account-card,
.google-account-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 1rem;
}
.repos-section,
.folders-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #e2e8f0;
}
.repos-header,
.folders-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.repos-header h5,
.folders-header h5 {
font-size: 0.9rem;
color: #4a5568;
font-weight: 500;
}
.repos-list,
.folders-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.repo-card,
.folder-card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 0.75rem;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
}
.repo-card.inactive,
.folder-card.inactive {
opacity: 0.6;
}
.repo-info,
.folder-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.repo-path {
font-weight: 500;
color: #2d3748;
}
.folder-name {
font-weight: 500;
color: #2d3748;
text-decoration: none;
}
a.folder-name:hover {
color: #667eea;
text-decoration: underline;
}
.folder-path {
font-size: 0.8rem;
color: #718096;
}
.repo-tracking,
.folder-settings {
display: flex;
gap: 0.375rem;
}
.tracking-badge,
.setting-badge {
background: #e2e8f0;
color: #4a5568;
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: 0.7rem;
}
.repo-actions,
.folder-actions {
display: flex;
gap: 0.375rem;
}
.no-repos,
.no-folders {
color: #718096;
font-size: 0.9rem;
font-style: italic;
}
.sync-error-banner {
background: #fff5f5;
color: #c53030;
padding: 0.5rem 0.75rem;
border-radius: 4px;
font-size: 0.85rem;
margin: 0.5rem 0;
}
/* Responsive */
@media (max-width: 768px) {
.sources-view {
padding: 1rem;
}
.sources-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.sources-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.sources-tabs .tab {
padding: 0.5rem 1rem;
white-space: nowrap;
}
.panel-header {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.source-card-header {
flex-direction: column;
gap: 0.5rem;
}
.source-card-actions {
flex-wrap: wrap;
}
.form-row {
grid-template-columns: 1fr;
}
.repo-card,
.folder-card {
flex-direction: column;
align-items: flex-start;
}
.repo-actions,
.folder-actions {
width: 100%;
justify-content: flex-start;
}
}
/* === Folder Browser === */
.folder-browser {
display: flex;
flex-direction: column;
min-height: 400px;
max-height: 60vh;
}
.folder-breadcrumb {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
padding: 0.75rem;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-size: 0.875rem;
}
.breadcrumb-sep {
color: #a0aec0;
margin: 0 0.25rem;
}
.breadcrumb-item {
background: none;
border: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
color: #667eea;
cursor: pointer;
font-size: 0.875rem;
}
.breadcrumb-item:hover:not(:disabled) {
background: #edf2f7;
}
.breadcrumb-item.current {
color: #2d3748;
font-weight: 500;
cursor: default;
}
.folder-loading,
.folder-empty {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: #718096;
font-size: 0.875rem;
padding: 2rem;
}
.folder-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.folder-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 6px;
transition: background 0.15s;
}
.folder-item:hover {
background: #f7fafc;
}
.folder-item.selected {
background: #ebf8ff;
}
.folder-item-checkbox {
display: flex;
align-items: center;
}
.folder-item-checkbox input {
width: 16px;
height: 16px;
cursor: pointer;
}
.folder-item-icon {
font-size: 1.25rem;
width: 24px;
text-align: center;
}
.folder-item-name {
flex: 1;
font-size: 0.875rem;
color: #2d3748;
text-decoration: none;
}
a.folder-item-name {
color: #4a5568;
}
a.folder-item-name:hover {
color: #667eea;
text-decoration: underline;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-item-recursive {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: #718096;
margin-left: auto;
padding: 0.25rem 0.5rem;
background: #f7fafc;
border-radius: 4px;
}
.folder-item-recursive input {
width: 14px;
height: 14px;
}
.folder-item-enter {
background: none;
border: 1px solid #e2e8f0;
border-radius: 4px;
padding: 0.25rem 0.5rem;
color: #667eea;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.15s;
}
.folder-item-enter:hover {
background: #667eea;
color: white;
border-color: #667eea;
}
.folder-browser-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
.selected-count {
font-size: 0.875rem;
color: #718096;
}
.folder-browser-actions {
display: flex;
gap: 0.5rem;
}
.folders-actions {
display: flex;
gap: 0.5rem;
}
.add-btn.secondary {
background: #f7fafc;
color: #4a5568;
border: 1px solid #e2e8f0;
}
.add-btn.secondary:hover {
background: #edf2f7;
}

View File

@ -4,7 +4,7 @@ import './App.css'
import { useAuth } from '@/hooks/useAuth'
import { useOAuth } from '@/hooks/useOAuth'
import { Loading, LoginPrompt, AuthError, Dashboard, Search } from '@/components'
import { Loading, LoginPrompt, AuthError, Dashboard, Search, Sources } from '@/components'
// AuthWrapper handles redirects based on auth state
const AuthWrapper = () => {
@ -94,6 +94,14 @@ const AuthWrapper = () => {
)
} />
<Route path="/ui/sources" element={
isAuthenticated ? (
<Sources />
) : (
<Navigate to="/ui/login" replace />
)
} />
{/* Default redirect */}
<Route path="/" element={
<Navigate to={isAuthenticated ? "/ui/dashboard" : "/ui/login"} replace />

View File

@ -26,6 +26,11 @@ const Dashboard = ({ onLogout }) => {
<p>Search through your knowledge base</p>
</Link>
<Link to="/ui/sources" className="feature-card">
<h3>Sources</h3>
<p>Manage email, GitHub, RSS feeds, and Google Drive</p>
</Link>
<div className="feature-card" onClick={async () => console.log(await listNotes())}>
<h3>📝 Notes</h3>
<p>Create and manage your notes</p>

View File

@ -1,5 +1,6 @@
export { default as Loading } from './Loading'
export { default as Dashboard } from './Dashboard'
export { default as Search } from './search'
export { default as Sources } from './sources'
export { default as LoginPrompt } from './auth/LoginPrompt'
export { default as AuthError } from './auth/AuthError'

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
export { default } from './Sources'

View File

@ -0,0 +1,310 @@
import { useState, useCallback } from 'react'
// Format relative time
export const formatRelativeTime = (dateString: string | null): string => {
if (!dateString) return 'Never'
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
}
// Status Badge
interface StatusBadgeProps {
active: boolean
onClick?: () => void
}
export const StatusBadge = ({ active, onClick }: StatusBadgeProps) => (
<span
className={`status-badge ${active ? 'active' : 'inactive'}`}
onClick={onClick}
style={{ cursor: onClick ? 'pointer' : 'default' }}
>
{active ? 'Active' : 'Inactive'}
</span>
)
// Sync Status
interface SyncStatusProps {
lastSyncAt: string | null
syncError?: string | null
}
export const SyncStatus = ({ lastSyncAt, syncError }: SyncStatusProps) => (
<div className="sync-status">
<span className="sync-time">
Last sync: {formatRelativeTime(lastSyncAt)}
</span>
{syncError && <span className="sync-error" title={syncError}>Error</span>}
</div>
)
// Sync Button
interface SyncButtonProps {
onSync: () => Promise<void>
disabled?: boolean
label?: string
}
export const SyncButton = ({ onSync, disabled, label = 'Sync' }: SyncButtonProps) => {
const [syncing, setSyncing] = useState(false)
const handleSync = async () => {
setSyncing(true)
try {
await onSync()
} finally {
setSyncing(false)
}
}
return (
<button
className="sync-btn"
onClick={handleSync}
disabled={disabled || syncing}
>
{syncing ? 'Syncing...' : label}
</button>
)
}
// Tags Input
interface TagsInputProps {
tags: string[]
onChange: (tags: string[]) => void
placeholder?: string
}
export const TagsInput = ({ tags, onChange, placeholder = 'Add tag...' }: TagsInputProps) => {
const [input, setInput] = useState('')
const addTag = () => {
const trimmed = input.trim()
if (trimmed && !tags.includes(trimmed)) {
onChange([...tags, trimmed])
setInput('')
}
}
const removeTag = (tag: string) => {
onChange(tags.filter(t => t !== tag))
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
addTag()
}
}
return (
<div className="tags-input">
<div className="tags-list">
{tags.map(tag => (
<span key={tag} className="tag">
{tag}
<button type="button" onClick={() => removeTag(tag)}>&times;</button>
</span>
))}
</div>
<input
type="text"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={addTag}
placeholder={placeholder}
className="tag-input"
/>
</div>
)
}
// Interval Input (minutes)
interface IntervalInputProps {
value: number
onChange: (value: number) => void
label?: string
}
export const IntervalInput = ({ value, onChange, label = 'Check interval' }: IntervalInputProps) => {
const hours = Math.floor(value / 60)
const minutes = value % 60
const setHours = (h: number) => onChange(h * 60 + minutes)
const setMinutes = (m: number) => onChange(hours * 60 + m)
return (
<div className="interval-input">
<label>{label}</label>
<div className="interval-controls">
<input
type="number"
min="0"
value={hours}
onChange={e => setHours(parseInt(e.target.value) || 0)}
/>
<span>h</span>
<input
type="number"
min="0"
max="59"
value={minutes}
onChange={e => setMinutes(parseInt(e.target.value) || 0)}
/>
<span>m</span>
</div>
</div>
)
}
// Confirm Dialog
interface ConfirmDialogProps {
message: string
onConfirm: () => void
onCancel: () => void
}
export const ConfirmDialog = ({ message, onConfirm, onCancel }: ConfirmDialogProps) => (
<div className="confirm-dialog-overlay">
<div className="confirm-dialog">
<p>{message}</p>
<div className="confirm-dialog-buttons">
<button className="cancel-btn" onClick={onCancel}>Cancel</button>
<button className="confirm-btn" onClick={onConfirm}>Confirm</button>
</div>
</div>
</div>
)
// Modal wrapper
interface ModalProps {
title: string
onClose: () => void
children: React.ReactNode
}
export const Modal = ({ title, onClose, children }: ModalProps) => (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>{title}</h3>
<button className="modal-close" onClick={onClose}>&times;</button>
</div>
<div className="modal-content">
{children}
</div>
</div>
</div>
)
// Source Card wrapper
interface SourceCardProps {
title: string
subtitle?: string
active: boolean
lastSyncAt: string | null
syncError?: string | null
onToggleActive?: () => void
onEdit?: () => void
onDelete?: () => void
onSync?: () => Promise<void>
children?: React.ReactNode
}
export const SourceCard = ({
title,
subtitle,
active,
lastSyncAt,
syncError,
onToggleActive,
onEdit,
onDelete,
onSync,
children,
}: SourceCardProps) => {
const [confirmDelete, setConfirmDelete] = useState(false)
return (
<div className={`source-card ${active ? '' : 'inactive'}`}>
<div className="source-card-header">
<div className="source-card-info">
<h4>{title}</h4>
{subtitle && <p className="source-subtitle">{subtitle}</p>}
</div>
<StatusBadge active={active} onClick={onToggleActive} />
</div>
<SyncStatus lastSyncAt={lastSyncAt} syncError={syncError} />
{children}
<div className="source-card-actions">
{onSync && <SyncButton onSync={onSync} disabled={!active} />}
{onEdit && <button className="edit-btn" onClick={onEdit}>Edit</button>}
{onDelete && (
<button className="delete-btn" onClick={() => setConfirmDelete(true)}>Delete</button>
)}
</div>
{confirmDelete && (
<ConfirmDialog
message={`Are you sure you want to delete "${title}"?`}
onConfirm={() => {
onDelete?.()
setConfirmDelete(false)
}}
onCancel={() => setConfirmDelete(false)}
/>
)}
</div>
)
}
// Empty state
interface EmptyStateProps {
message: string
actionLabel?: string
onAction?: () => void
}
export const EmptyState = ({ message, actionLabel, onAction }: EmptyStateProps) => (
<div className="empty-state">
<p>{message}</p>
{actionLabel && onAction && (
<button className="add-btn" onClick={onAction}>{actionLabel}</button>
)}
</div>
)
// Loading state
export const LoadingState = () => (
<div className="loading-state">
<div className="loading-spinner"></div>
<p>Loading...</p>
</div>
)
// Error state
interface ErrorStateProps {
message: string
onRetry?: () => void
}
export const ErrorState = ({ message, onRetry }: ErrorStateProps) => (
<div className="error-state">
<p>{message}</p>
{onRetry && <button className="retry-btn" onClick={onRetry}>Retry</button>}
</div>
)

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
export const SERVER_URL = import.meta.env.VITE_SERVER_URL || 'http://localhost:8000'
export const SERVER_URL = import.meta.env.VITE_SERVER_URL || ''
export const SESSION_COOKIE_NAME = import.meta.env.VITE_SESSION_COOKIE_NAME || 'session_id'
// Cookie utilities

View File

@ -1,6 +1,6 @@
import { useState, useCallback } from 'react'
const SERVER_URL = import.meta.env.VITE_SERVER_URL || 'http://localhost:8000'
const SERVER_URL = import.meta.env.VITE_SERVER_URL || ''
const SESSION_COOKIE_NAME = import.meta.env.VITE_SESSION_COOKIE_NAME || 'session_id'
const REDIRECT_URI = `${window.location.origin}/ui`
@ -94,8 +94,8 @@ export const useOAuth = () => {
localStorage.setItem('oauth_state', state)
localStorage.setItem('code_verifier', codeVerifier)
// Build authorization URL
const authUrl = new URL(`${SERVER_URL}/authorize`)
// Build authorization URL (needs full URL for browser redirect)
const authUrl = new URL(`${window.location.origin}/authorize`)
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('client_id', clientId)
authUrl.searchParams.set('redirect_uri', REDIRECT_URI)

View File

@ -0,0 +1,566 @@
import { useCallback } from 'react'
import { useAuth, SERVER_URL } from './useAuth'
// Types for Email Accounts
export interface EmailAccount {
id: number
name: string
email_address: string
imap_server: string
imap_port: number
username: string
use_ssl: boolean
folders: string[]
tags: string[]
last_sync_at: string | null
active: boolean
created_at: string
updated_at: string
}
export interface EmailAccountCreate {
name: string
email_address: string
imap_server: string
imap_port?: number
username: string
password: string
use_ssl?: boolean
folders?: string[]
tags?: string[]
}
export interface EmailAccountUpdate {
name?: string
imap_server?: string
imap_port?: number
username?: string
password?: string
use_ssl?: boolean
folders?: string[]
tags?: string[]
active?: boolean
}
// Types for Article Feeds
export interface ArticleFeed {
id: number
url: string
title: string | null
description: string | null
tags: string[]
check_interval: number
last_checked_at: string | null
active: boolean
created_at: string
updated_at: string
}
export interface ArticleFeedCreate {
url: string
title?: string
description?: string
tags?: string[]
check_interval?: number
active?: boolean
}
export interface ArticleFeedUpdate {
title?: string
description?: string
tags?: string[]
check_interval?: number
active?: boolean
}
// Types for GitHub
export interface GithubRepo {
id: number
account_id: number
owner: string
name: string
repo_path: string
track_issues: boolean
track_prs: boolean
track_comments: boolean
track_project_fields: boolean
labels_filter: string[]
state_filter: string | null
tags: string[]
check_interval: number
full_sync_interval: number
last_sync_at: string | null
last_full_sync_at: string | null
active: boolean
created_at: string
}
export interface GithubAccount {
id: number
name: string
auth_type: 'pat' | 'app'
has_access_token: boolean
has_private_key: boolean
app_id: number | null
installation_id: number | null
active: boolean
last_sync_at: string | null
created_at: string
updated_at: string
repos: GithubRepo[]
}
export interface GithubAccountCreate {
name: string
auth_type: 'pat' | 'app'
access_token?: string
app_id?: number
installation_id?: number
private_key?: string
}
export interface GithubAccountUpdate {
name?: string
access_token?: string
app_id?: number
installation_id?: number
private_key?: string
active?: boolean
}
export interface GithubRepoCreate {
owner: string
name: string
track_issues?: boolean
track_prs?: boolean
track_comments?: boolean
track_project_fields?: boolean
labels_filter?: string[]
state_filter?: string
tags?: string[]
check_interval?: number
full_sync_interval?: number
}
export interface GithubRepoUpdate {
track_issues?: boolean
track_prs?: boolean
track_comments?: boolean
track_project_fields?: boolean
labels_filter?: string[]
state_filter?: string
tags?: string[]
check_interval?: number
full_sync_interval?: number
active?: boolean
}
// Types for Google OAuth Config
export interface GoogleOAuthConfig {
id: number
name: string
client_id: string
project_id: string | null
redirect_uris: string[]
created_at: string
}
// Types for Google Drive
export interface GoogleFolder {
id: number
folder_id: string
folder_name: string
folder_path: string | null
recursive: boolean
include_shared: boolean
tags: string[]
check_interval: number
last_sync_at: string | null
active: boolean
}
export interface GoogleAccount {
id: number
name: string
email: string
active: boolean
last_sync_at: string | null
sync_error: string | null
folders: GoogleFolder[]
}
export interface GoogleFolderCreate {
folder_id: string
folder_name: string
recursive?: boolean
include_shared?: boolean
tags?: string[]
check_interval?: number
}
export interface GoogleFolderUpdate {
folder_name?: string
recursive?: boolean
include_shared?: boolean
tags?: string[]
check_interval?: number
active?: boolean
}
// Types for Google Drive browsing
export interface DriveItem {
id: string
name: string
mime_type: string
is_folder: boolean
size: number | null
modified_at: string | null
}
export interface BrowseResponse {
folder_id: string
folder_name: string
parent_id: string | null
items: DriveItem[]
next_page_token: string | null
}
// Task response
export interface TaskResponse {
task_id: string
status: string
}
export const useSources = () => {
const { apiCall } = useAuth()
// === Email Accounts ===
const listEmailAccounts = useCallback(async (): Promise<EmailAccount[]> => {
const response = await apiCall('/email-accounts')
if (!response.ok) throw new Error('Failed to fetch email accounts')
return response.json()
}, [apiCall])
const createEmailAccount = useCallback(async (data: EmailAccountCreate): Promise<EmailAccount> => {
const response = await apiCall('/email-accounts', {
method: 'POST',
body: JSON.stringify(data),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Failed to create email account')
}
return response.json()
}, [apiCall])
const updateEmailAccount = useCallback(async (id: number, data: EmailAccountUpdate): Promise<EmailAccount> => {
const response = await apiCall(`/email-accounts/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Failed to update email account')
}
return response.json()
}, [apiCall])
const deleteEmailAccount = useCallback(async (id: number): Promise<void> => {
const response = await apiCall(`/email-accounts/${id}`, { method: 'DELETE' })
if (!response.ok) throw new Error('Failed to delete email account')
}, [apiCall])
const syncEmailAccount = useCallback(async (id: number): Promise<TaskResponse> => {
const response = await apiCall(`/email-accounts/${id}/sync`, { method: 'POST' })
if (!response.ok) throw new Error('Failed to sync email account')
return response.json()
}, [apiCall])
const testEmailAccount = useCallback(async (id: number): Promise<{ status: string; message: string }> => {
const response = await apiCall(`/email-accounts/${id}/test`, { method: 'POST' })
if (!response.ok) throw new Error('Failed to test email account')
return response.json()
}, [apiCall])
// === Article Feeds ===
const listArticleFeeds = useCallback(async (): Promise<ArticleFeed[]> => {
const response = await apiCall('/article-feeds')
if (!response.ok) throw new Error('Failed to fetch article feeds')
return response.json()
}, [apiCall])
const createArticleFeed = useCallback(async (data: ArticleFeedCreate): Promise<ArticleFeed> => {
const response = await apiCall('/article-feeds', {
method: 'POST',
body: JSON.stringify(data),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Failed to create article feed')
}
return response.json()
}, [apiCall])
const updateArticleFeed = useCallback(async (id: number, data: ArticleFeedUpdate): Promise<ArticleFeed> => {
const response = await apiCall(`/article-feeds/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Failed to update article feed')
}
return response.json()
}, [apiCall])
const deleteArticleFeed = useCallback(async (id: number): Promise<void> => {
const response = await apiCall(`/article-feeds/${id}`, { method: 'DELETE' })
if (!response.ok) throw new Error('Failed to delete article feed')
}, [apiCall])
const syncArticleFeed = useCallback(async (id: number): Promise<TaskResponse> => {
const response = await apiCall(`/article-feeds/${id}/sync`, { method: 'POST' })
if (!response.ok) throw new Error('Failed to sync article feed')
return response.json()
}, [apiCall])
const discoverFeed = useCallback(async (url: string): Promise<{ url: string; title: string | null; description: string | null }> => {
const response = await apiCall(`/article-feeds/discover?url=${encodeURIComponent(url)}`, {
method: 'POST',
})
if (!response.ok) throw new Error('Failed to discover feed')
return response.json()
}, [apiCall])
// === GitHub Accounts ===
const listGithubAccounts = useCallback(async (): Promise<GithubAccount[]> => {
const response = await apiCall('/github/accounts')
if (!response.ok) throw new Error('Failed to fetch GitHub accounts')
return response.json()
}, [apiCall])
const createGithubAccount = useCallback(async (data: GithubAccountCreate): Promise<GithubAccount> => {
const response = await apiCall('/github/accounts', {
method: 'POST',
body: JSON.stringify(data),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Failed to create GitHub account')
}
return response.json()
}, [apiCall])
const updateGithubAccount = useCallback(async (id: number, data: GithubAccountUpdate): Promise<GithubAccount> => {
const response = await apiCall(`/github/accounts/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Failed to update GitHub account')
}
return response.json()
}, [apiCall])
const deleteGithubAccount = useCallback(async (id: number): Promise<void> => {
const response = await apiCall(`/github/accounts/${id}`, { method: 'DELETE' })
if (!response.ok) throw new Error('Failed to delete GitHub account')
}, [apiCall])
const validateGithubAccount = useCallback(async (id: number): Promise<{ status: string; message: string }> => {
const response = await apiCall(`/github/accounts/${id}/validate`, { method: 'POST' })
if (!response.ok) throw new Error('Failed to validate GitHub account')
return response.json()
}, [apiCall])
// === GitHub Repos ===
const addGithubRepo = useCallback(async (accountId: number, data: GithubRepoCreate): Promise<GithubRepo> => {
const response = await apiCall(`/github/accounts/${accountId}/repos`, {
method: 'POST',
body: JSON.stringify(data),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Failed to add GitHub repo')
}
return response.json()
}, [apiCall])
const updateGithubRepo = useCallback(async (accountId: number, repoId: number, data: GithubRepoUpdate): Promise<GithubRepo> => {
const response = await apiCall(`/github/accounts/${accountId}/repos/${repoId}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Failed to update GitHub repo')
}
return response.json()
}, [apiCall])
const deleteGithubRepo = useCallback(async (accountId: number, repoId: number): Promise<void> => {
const response = await apiCall(`/github/accounts/${accountId}/repos/${repoId}`, { method: 'DELETE' })
if (!response.ok) throw new Error('Failed to delete GitHub repo')
}, [apiCall])
const syncGithubRepo = useCallback(async (accountId: number, repoId: number, forceFull = false): Promise<TaskResponse> => {
const response = await apiCall(`/github/accounts/${accountId}/repos/${repoId}/sync?force_full=${forceFull}`, { method: 'POST' })
if (!response.ok) throw new Error('Failed to sync GitHub repo')
return response.json()
}, [apiCall])
// === Google Drive ===
const listGoogleAccounts = useCallback(async (): Promise<GoogleAccount[]> => {
const response = await apiCall('/google-drive/accounts')
if (!response.ok) throw new Error('Failed to fetch Google accounts')
return response.json()
}, [apiCall])
const getGoogleAuthUrl = useCallback(async (): Promise<{ authorization_url: string }> => {
const response = await apiCall('/google-drive/authorize')
if (!response.ok) throw new Error('Failed to get Google auth URL')
return response.json()
}, [apiCall])
const deleteGoogleAccount = useCallback(async (id: number): Promise<void> => {
const response = await apiCall(`/google-drive/accounts/${id}`, { method: 'DELETE' })
if (!response.ok) throw new Error('Failed to delete Google account')
}, [apiCall])
const browseGoogleDrive = useCallback(async (
accountId: number,
folderId: string = 'root',
pageToken?: string
): Promise<BrowseResponse> => {
const params = new URLSearchParams({ folder_id: folderId })
if (pageToken) params.append('page_token', pageToken)
const response = await apiCall(`/google-drive/accounts/${accountId}/browse?${params}`)
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Failed to browse Google Drive')
}
return response.json()
}, [apiCall])
const addGoogleFolder = useCallback(async (accountId: number, data: GoogleFolderCreate): Promise<GoogleFolder> => {
const response = await apiCall(`/google-drive/accounts/${accountId}/folders`, {
method: 'POST',
body: JSON.stringify(data),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Failed to add Google folder')
}
return response.json()
}, [apiCall])
const updateGoogleFolder = useCallback(async (accountId: number, folderId: number, data: GoogleFolderUpdate): Promise<GoogleFolder> => {
const response = await apiCall(`/google-drive/accounts/${accountId}/folders/${folderId}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Failed to update Google folder')
}
return response.json()
}, [apiCall])
const deleteGoogleFolder = useCallback(async (accountId: number, folderId: number): Promise<void> => {
const response = await apiCall(`/google-drive/accounts/${accountId}/folders/${folderId}`, { method: 'DELETE' })
if (!response.ok) throw new Error('Failed to delete Google folder')
}, [apiCall])
const syncGoogleFolder = useCallback(async (accountId: number, folderId: number, forceFull = false): Promise<TaskResponse> => {
const response = await apiCall(`/google-drive/accounts/${accountId}/folders/${folderId}/sync?force_full=${forceFull}`, { method: 'POST' })
if (!response.ok) throw new Error('Failed to sync Google folder')
return response.json()
}, [apiCall])
// === Google OAuth Config ===
const getGoogleOAuthConfig = useCallback(async (): Promise<GoogleOAuthConfig | null> => {
const response = await apiCall('/google-drive/config')
if (!response.ok) {
if (response.status === 404) return null
throw new Error('Failed to fetch Google OAuth config')
}
return response.json()
}, [apiCall])
const uploadGoogleOAuthConfig = useCallback(async (file: File): Promise<GoogleOAuthConfig> => {
const formData = new FormData()
formData.append('file', file)
const accessToken = document.cookie
.split('; ')
.find(row => row.startsWith('access_token='))
?.split('=')[1]
const response = await fetch('/google-drive/config', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
},
body: formData,
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Failed to upload OAuth config')
}
return response.json()
}, [])
const deleteGoogleOAuthConfig = useCallback(async (): Promise<void> => {
const response = await apiCall('/google-drive/config', { method: 'DELETE' })
if (!response.ok) throw new Error('Failed to delete Google OAuth config')
}, [apiCall])
return {
// Email
listEmailAccounts,
createEmailAccount,
updateEmailAccount,
deleteEmailAccount,
syncEmailAccount,
testEmailAccount,
// Article Feeds
listArticleFeeds,
createArticleFeed,
updateArticleFeed,
deleteArticleFeed,
syncArticleFeed,
discoverFeed,
// GitHub Accounts
listGithubAccounts,
createGithubAccount,
updateGithubAccount,
deleteGithubAccount,
validateGithubAccount,
// GitHub Repos
addGithubRepo,
updateGithubRepo,
deleteGithubRepo,
syncGithubRepo,
// Google Drive
listGoogleAccounts,
getGoogleAuthUrl,
deleteGoogleAccount,
browseGoogleDrive,
addGoogleFolder,
updateGoogleFolder,
deleteGoogleFolder,
syncGoogleFolder,
// Google OAuth Config
getGoogleOAuthConfig,
uploadGoogleOAuthConfig,
deleteGoogleOAuthConfig,
}
}

View File

@ -11,4 +11,18 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
// Proxy API requests to backend during development
'/register': 'http://localhost:8000',
'/authorize': 'http://localhost:8000',
'/token': 'http://localhost:8000',
'/auth': 'http://localhost:8000',
'/health': 'http://localhost:8000',
'/email-accounts': 'http://localhost:8000',
'/article-feeds': 'http://localhost:8000',
'/github': 'http://localhost:8000',
'/google-drive': 'http://localhost:8000',
},
},
})

View File

@ -1,3 +1,2 @@
discord.py==2.3.2
uvicorn==0.29.0
fastapi==0.112.2
uvicorn==0.29.0

View File

@ -5,3 +5,8 @@ markdownify==0.13.1
pillow==10.3.0
pypandoc==1.15
feedparser==6.0.10
# Google Drive API
google-auth>=2.0.0
google-auth-oauthlib>=1.0.0
google-api-python-client>=2.0.0

View File

@ -23,6 +23,10 @@ from memory.api.auth import (
AuthenticationMiddleware,
router as auth_router,
)
from memory.api.google_drive import router as google_drive_router
from memory.api.email_accounts import router as email_accounts_router
from memory.api.article_feeds import router as article_feeds_router
from memory.api.github_sources import router as github_sources_router
from memory.api.MCP.base import mcp
logger = logging.getLogger(__name__)
@ -60,7 +64,7 @@ app.add_middleware(AuthenticationMiddleware)
# allow_credentials=True requires specific origins, not wildcards.
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.SERVER_URL],
allow_origins=[settings.SERVER_URL, "http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@ -149,6 +153,10 @@ admin = Admin(app, engine)
# Setup admin with OAuth protection using existing OAuth provider
setup_admin(admin)
app.include_router(auth_router)
app.include_router(google_drive_router)
app.include_router(email_accounts_router)
app.include_router(article_feeds_router)
app.include_router(github_sources_router)
# Add health check to MCP server instead of main app

View File

@ -0,0 +1,203 @@
"""API endpoints for Article Feed management."""
from typing import cast
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, HttpUrl
from sqlalchemy.orm import Session
from memory.common.db.connection import get_session
from memory.common.db.models import User
from memory.common.db.models.sources import ArticleFeed
from memory.api.auth import get_current_user
router = APIRouter(prefix="/article-feeds", tags=["article-feeds"])
class ArticleFeedCreate(BaseModel):
url: HttpUrl
title: str | None = None
description: str | None = None
tags: list[str] = []
check_interval: int = 1440 # 24 hours in minutes
active: bool = True
class ArticleFeedUpdate(BaseModel):
title: str | None = None
description: str | None = None
tags: list[str] | None = None
check_interval: int | None = None
active: bool | None = None
class ArticleFeedResponse(BaseModel):
id: int
url: str
title: str | None
description: str | None
tags: list[str]
check_interval: int
last_checked_at: str | None
active: bool
created_at: str
updated_at: str
class FeedDiscoveryResponse(BaseModel):
url: str
title: str | None
description: str | None
def feed_to_response(feed: ArticleFeed) -> ArticleFeedResponse:
"""Convert an ArticleFeed model to a response model."""
return ArticleFeedResponse(
id=cast(int, feed.id),
url=cast(str, feed.url),
title=cast(str | None, feed.title),
description=cast(str | None, feed.description),
tags=list(feed.tags or []),
check_interval=cast(int, feed.check_interval),
last_checked_at=feed.last_checked_at.isoformat() if feed.last_checked_at else None,
active=cast(bool, feed.active),
created_at=feed.created_at.isoformat() if feed.created_at else "",
updated_at=feed.updated_at.isoformat() if feed.updated_at else "",
)
@router.get("")
def list_feeds(
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> list[ArticleFeedResponse]:
"""List all article feeds."""
feeds = db.query(ArticleFeed).all()
return [feed_to_response(feed) for feed in feeds]
@router.post("")
def create_feed(
data: ArticleFeedCreate,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> ArticleFeedResponse:
"""Create a new article feed."""
url_str = str(data.url)
# Check for duplicate URL
existing = db.query(ArticleFeed).filter(ArticleFeed.url == url_str).first()
if existing:
raise HTTPException(status_code=400, detail="Feed with this URL already exists")
feed = ArticleFeed(
url=url_str,
title=data.title or url_str,
description=data.description,
tags=data.tags,
check_interval=data.check_interval,
active=data.active,
)
db.add(feed)
db.commit()
db.refresh(feed)
return feed_to_response(feed)
@router.get("/{feed_id}")
def get_feed(
feed_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> ArticleFeedResponse:
"""Get a single article feed."""
feed = db.get(ArticleFeed, feed_id)
if not feed:
raise HTTPException(status_code=404, detail="Feed not found")
return feed_to_response(feed)
@router.patch("/{feed_id}")
def update_feed(
feed_id: int,
updates: ArticleFeedUpdate,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> ArticleFeedResponse:
"""Update an article feed."""
feed = db.get(ArticleFeed, feed_id)
if not feed:
raise HTTPException(status_code=404, detail="Feed not found")
if updates.title is not None:
feed.title = updates.title
if updates.description is not None:
feed.description = updates.description
if updates.tags is not None:
feed.tags = updates.tags
if updates.check_interval is not None:
feed.check_interval = updates.check_interval
if updates.active is not None:
feed.active = updates.active
db.commit()
db.refresh(feed)
return feed_to_response(feed)
@router.delete("/{feed_id}")
def delete_feed(
feed_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
):
"""Delete an article feed."""
feed = db.get(ArticleFeed, feed_id)
if not feed:
raise HTTPException(status_code=404, detail="Feed not found")
db.delete(feed)
db.commit()
return {"status": "deleted"}
@router.post("/{feed_id}/sync")
def trigger_sync(
feed_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
):
"""Manually trigger a sync for an article feed."""
from memory.workers.tasks.blogs import sync_article_feed
feed = db.get(ArticleFeed, feed_id)
if not feed:
raise HTTPException(status_code=404, detail="Feed not found")
task = sync_article_feed.delay(feed_id)
return {"task_id": task.id, "status": "scheduled"}
@router.post("/discover")
def discover_feed(
url: HttpUrl,
user: User = Depends(get_current_user),
) -> FeedDiscoveryResponse:
"""Auto-discover feed metadata from a URL."""
from memory.parsers.feeds import get_feed_parser
url_str = str(url)
parser = get_feed_parser(url_str)
if not parser:
raise HTTPException(status_code=400, detail="Could not parse feed from URL")
return FeedDiscoveryResponse(
url=url_str,
title=parser.title,
description=parser.description,
)

View File

@ -37,6 +37,7 @@ WHITELIST = {
"/.well-known/",
"/ui",
"/admin/statics/", # SQLAdmin static resources
"/google-drive/callback", # Google OAuth callback
}

View File

@ -0,0 +1,233 @@
"""API endpoints for Email Account management."""
from typing import cast
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
from memory.common.db.connection import get_session
from memory.common.db.models import User
from memory.common.db.models.sources import EmailAccount
from memory.api.auth import get_current_user
router = APIRouter(prefix="/email-accounts", tags=["email-accounts"])
class EmailAccountCreate(BaseModel):
name: str
email_address: EmailStr
imap_server: str
imap_port: int = 993
username: str
password: str
use_ssl: bool = True
folders: list[str] = []
tags: list[str] = []
class EmailAccountUpdate(BaseModel):
name: str | None = None
imap_server: str | None = None
imap_port: int | None = None
username: str | None = None
password: str | None = None
use_ssl: bool | None = None
folders: list[str] | None = None
tags: list[str] | None = None
active: bool | None = None
class EmailAccountResponse(BaseModel):
id: int
name: str
email_address: str
imap_server: str
imap_port: int
username: str
use_ssl: bool
folders: list[str]
tags: list[str]
last_sync_at: str | None
active: bool
created_at: str
updated_at: str
def account_to_response(account: EmailAccount) -> EmailAccountResponse:
"""Convert an EmailAccount model to a response model."""
return EmailAccountResponse(
id=cast(int, account.id),
name=cast(str, account.name),
email_address=cast(str, account.email_address),
imap_server=cast(str, account.imap_server),
imap_port=cast(int, account.imap_port),
username=cast(str, account.username),
use_ssl=cast(bool, account.use_ssl),
folders=list(account.folders or []),
tags=list(account.tags or []),
last_sync_at=account.last_sync_at.isoformat() if account.last_sync_at else None,
active=cast(bool, account.active),
created_at=account.created_at.isoformat() if account.created_at else "",
updated_at=account.updated_at.isoformat() if account.updated_at else "",
)
@router.get("")
def list_accounts(
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> list[EmailAccountResponse]:
"""List all email accounts."""
accounts = db.query(EmailAccount).all()
return [account_to_response(account) for account in accounts]
@router.post("")
def create_account(
data: EmailAccountCreate,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> EmailAccountResponse:
"""Create a new email account."""
# Check for duplicate email address
existing = (
db.query(EmailAccount)
.filter(EmailAccount.email_address == data.email_address)
.first()
)
if existing:
raise HTTPException(status_code=400, detail="Email account already exists")
account = EmailAccount(
name=data.name,
email_address=data.email_address,
imap_server=data.imap_server,
imap_port=data.imap_port,
username=data.username,
password=data.password,
use_ssl=data.use_ssl,
folders=data.folders,
tags=data.tags,
)
db.add(account)
db.commit()
db.refresh(account)
return account_to_response(account)
@router.get("/{account_id}")
def get_account(
account_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> EmailAccountResponse:
"""Get a single email account."""
account = db.get(EmailAccount, account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
return account_to_response(account)
@router.patch("/{account_id}")
def update_account(
account_id: int,
updates: EmailAccountUpdate,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> EmailAccountResponse:
"""Update an email account."""
account = db.get(EmailAccount, account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
if updates.name is not None:
account.name = updates.name
if updates.imap_server is not None:
account.imap_server = updates.imap_server
if updates.imap_port is not None:
account.imap_port = updates.imap_port
if updates.username is not None:
account.username = updates.username
if updates.password is not None:
account.password = updates.password
if updates.use_ssl is not None:
account.use_ssl = updates.use_ssl
if updates.folders is not None:
account.folders = updates.folders
if updates.tags is not None:
account.tags = updates.tags
if updates.active is not None:
account.active = updates.active
db.commit()
db.refresh(account)
return account_to_response(account)
@router.delete("/{account_id}")
def delete_account(
account_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
):
"""Delete an email account."""
account = db.get(EmailAccount, account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
db.delete(account)
db.commit()
return {"status": "deleted"}
@router.post("/{account_id}/sync")
def trigger_sync(
account_id: int,
since_date: str | None = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
):
"""Manually trigger a sync for an email account."""
from memory.workers.tasks.email import sync_account
account = db.get(EmailAccount, account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
task = sync_account.delay(account_id, since_date=since_date)
return {"task_id": task.id, "status": "scheduled"}
@router.post("/{account_id}/test")
def test_connection(
account_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
):
"""Test IMAP connection for an email account."""
from memory.workers.email import imap_connection
account = db.get(EmailAccount, account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
try:
with imap_connection(account) as conn:
# List folders to verify connection works
status, folders = conn.list()
if status != "OK":
return {"status": "error", "message": "Failed to list folders"}
folder_count = len(folders) if folders else 0
return {
"status": "success",
"message": f"Connected successfully. Found {folder_count} folders.",
"folders": folder_count,
}
except Exception as e:
return {"status": "error", "message": str(e)}

View File

@ -0,0 +1,447 @@
"""API endpoints for GitHub Account and Repo management."""
from typing import Literal, cast
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from memory.common.db.connection import get_session
from memory.common.db.models import User
from memory.common.db.models.sources import GithubAccount, GithubRepo
from memory.api.auth import get_current_user
router = APIRouter(prefix="/github", tags=["github"])
# --- Account Models ---
class GithubAccountCreate(BaseModel):
name: str
auth_type: Literal["pat", "app"]
# For PAT auth
access_token: str | None = None
# For App auth
app_id: int | None = None
installation_id: int | None = None
private_key: str | None = None
class GithubAccountUpdate(BaseModel):
name: str | None = None
access_token: str | None = None
app_id: int | None = None
installation_id: int | None = None
private_key: str | None = None
active: bool | None = None
class GithubRepoResponse(BaseModel):
id: int
account_id: int
owner: str
name: str
repo_path: str
track_issues: bool
track_prs: bool
track_comments: bool
track_project_fields: bool
labels_filter: list[str]
state_filter: str | None
tags: list[str]
check_interval: int
full_sync_interval: int
last_sync_at: str | None
last_full_sync_at: str | None
active: bool
created_at: str
class GithubAccountResponse(BaseModel):
id: int
name: str
auth_type: str
has_access_token: bool
has_private_key: bool
app_id: int | None
installation_id: int | None
active: bool
last_sync_at: str | None
created_at: str
updated_at: str
repos: list[GithubRepoResponse]
# --- Repo Models ---
class GithubRepoCreate(BaseModel):
owner: str
name: str
track_issues: bool = True
track_prs: bool = True
track_comments: bool = True
track_project_fields: bool = False
labels_filter: list[str] = []
state_filter: str | None = None
tags: list[str] = []
check_interval: int = 60
full_sync_interval: int = 1440
class GithubRepoUpdate(BaseModel):
track_issues: bool | None = None
track_prs: bool | None = None
track_comments: bool | None = None
track_project_fields: bool | None = None
labels_filter: list[str] | None = None
state_filter: str | None = None
tags: list[str] | None = None
check_interval: int | None = None
full_sync_interval: int | None = None
active: bool | None = None
# --- Helper Functions ---
def repo_to_response(repo: GithubRepo) -> GithubRepoResponse:
"""Convert a GithubRepo model to a response model."""
return GithubRepoResponse(
id=cast(int, repo.id),
account_id=cast(int, repo.account_id),
owner=cast(str, repo.owner),
name=cast(str, repo.name),
repo_path=repo.repo_path,
track_issues=cast(bool, repo.track_issues),
track_prs=cast(bool, repo.track_prs),
track_comments=cast(bool, repo.track_comments),
track_project_fields=cast(bool, repo.track_project_fields),
labels_filter=list(repo.labels_filter or []),
state_filter=cast(str | None, repo.state_filter),
tags=list(repo.tags or []),
check_interval=cast(int, repo.check_interval),
full_sync_interval=cast(int, repo.full_sync_interval),
last_sync_at=repo.last_sync_at.isoformat() if repo.last_sync_at else None,
last_full_sync_at=repo.last_full_sync_at.isoformat() if repo.last_full_sync_at else None,
active=cast(bool, repo.active),
created_at=repo.created_at.isoformat() if repo.created_at else "",
)
def account_to_response(account: GithubAccount) -> GithubAccountResponse:
"""Convert a GithubAccount model to a response model."""
return GithubAccountResponse(
id=cast(int, account.id),
name=cast(str, account.name),
auth_type=cast(str, account.auth_type),
has_access_token=bool(account.access_token),
has_private_key=bool(account.private_key),
app_id=cast(int | None, account.app_id),
installation_id=cast(int | None, account.installation_id),
active=cast(bool, account.active),
last_sync_at=account.last_sync_at.isoformat() if account.last_sync_at else None,
created_at=account.created_at.isoformat() if account.created_at else "",
updated_at=account.updated_at.isoformat() if account.updated_at else "",
repos=[repo_to_response(repo) for repo in account.repos],
)
# --- Account Endpoints ---
@router.get("/accounts")
def list_accounts(
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> list[GithubAccountResponse]:
"""List all GitHub accounts with their repos."""
accounts = db.query(GithubAccount).all()
return [account_to_response(account) for account in accounts]
@router.post("/accounts")
def create_account(
data: GithubAccountCreate,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> GithubAccountResponse:
"""Create a new GitHub account."""
# Validate auth configuration
if data.auth_type == "pat":
if not data.access_token:
raise HTTPException(
status_code=400,
detail="access_token is required for PAT authentication",
)
elif data.auth_type == "app":
if not all([data.app_id, data.installation_id, data.private_key]):
raise HTTPException(
status_code=400,
detail="app_id, installation_id, and private_key are required for App authentication",
)
account = GithubAccount(
name=data.name,
auth_type=data.auth_type,
access_token=data.access_token,
app_id=data.app_id,
installation_id=data.installation_id,
private_key=data.private_key,
)
db.add(account)
db.commit()
db.refresh(account)
return account_to_response(account)
@router.get("/accounts/{account_id}")
def get_account(
account_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> GithubAccountResponse:
"""Get a single GitHub account."""
account = db.get(GithubAccount, account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
return account_to_response(account)
@router.patch("/accounts/{account_id}")
def update_account(
account_id: int,
updates: GithubAccountUpdate,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> GithubAccountResponse:
"""Update a GitHub account."""
account = db.get(GithubAccount, account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
if updates.name is not None:
account.name = updates.name
if updates.access_token is not None:
account.access_token = updates.access_token
if updates.app_id is not None:
account.app_id = updates.app_id
if updates.installation_id is not None:
account.installation_id = updates.installation_id
if updates.private_key is not None:
account.private_key = updates.private_key
if updates.active is not None:
account.active = updates.active
db.commit()
db.refresh(account)
return account_to_response(account)
@router.delete("/accounts/{account_id}")
def delete_account(
account_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
):
"""Delete a GitHub account and all its repos."""
account = db.get(GithubAccount, account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
db.delete(account)
db.commit()
return {"status": "deleted"}
@router.post("/accounts/{account_id}/validate")
def validate_account(
account_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
):
"""Validate GitHub API access for an account."""
from memory.parsers.github import GithubClient, GithubCredentials
account = db.get(GithubAccount, account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
try:
credentials = GithubCredentials(
auth_type=cast(str, account.auth_type),
access_token=cast(str | None, account.access_token),
app_id=cast(int | None, account.app_id),
installation_id=cast(int | None, account.installation_id),
private_key=cast(str | None, account.private_key),
)
client = GithubClient(credentials)
# Test by getting authenticated user info
user_info = client.get_authenticated_user()
return {
"status": "success",
"message": f"Authenticated as {user_info.get('login', 'unknown')}",
"user": user_info.get("login"),
"scopes": user_info.get("scopes", []),
}
except Exception as e:
return {"status": "error", "message": str(e)}
# --- Repo Endpoints ---
@router.get("/accounts/{account_id}/repos")
def list_repos(
account_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> list[GithubRepoResponse]:
"""List all repos for a GitHub account."""
account = db.get(GithubAccount, account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
return [repo_to_response(repo) for repo in account.repos]
@router.post("/accounts/{account_id}/repos")
def add_repo(
account_id: int,
data: GithubRepoCreate,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> GithubRepoResponse:
"""Add a repo to track for a GitHub account."""
account = db.get(GithubAccount, account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
# Check for duplicate
existing = (
db.query(GithubRepo)
.filter(
GithubRepo.account_id == account_id,
GithubRepo.owner == data.owner,
GithubRepo.name == data.name,
)
.first()
)
if existing:
raise HTTPException(status_code=400, detail="Repo already tracked")
repo = GithubRepo(
account_id=account_id,
owner=data.owner,
name=data.name,
track_issues=data.track_issues,
track_prs=data.track_prs,
track_comments=data.track_comments,
track_project_fields=data.track_project_fields,
labels_filter=data.labels_filter,
state_filter=data.state_filter,
tags=data.tags,
check_interval=data.check_interval,
full_sync_interval=data.full_sync_interval,
)
db.add(repo)
db.commit()
db.refresh(repo)
return repo_to_response(repo)
@router.patch("/accounts/{account_id}/repos/{repo_id}")
def update_repo(
account_id: int,
repo_id: int,
updates: GithubRepoUpdate,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> GithubRepoResponse:
"""Update a repo's tracking configuration."""
repo = (
db.query(GithubRepo)
.filter(GithubRepo.account_id == account_id, GithubRepo.id == repo_id)
.first()
)
if not repo:
raise HTTPException(status_code=404, detail="Repo not found")
if updates.track_issues is not None:
repo.track_issues = updates.track_issues
if updates.track_prs is not None:
repo.track_prs = updates.track_prs
if updates.track_comments is not None:
repo.track_comments = updates.track_comments
if updates.track_project_fields is not None:
repo.track_project_fields = updates.track_project_fields
if updates.labels_filter is not None:
repo.labels_filter = updates.labels_filter
if updates.state_filter is not None:
repo.state_filter = updates.state_filter
if updates.tags is not None:
repo.tags = updates.tags
if updates.check_interval is not None:
repo.check_interval = updates.check_interval
if updates.full_sync_interval is not None:
repo.full_sync_interval = updates.full_sync_interval
if updates.active is not None:
repo.active = updates.active
db.commit()
db.refresh(repo)
return repo_to_response(repo)
@router.delete("/accounts/{account_id}/repos/{repo_id}")
def remove_repo(
account_id: int,
repo_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
):
"""Remove a repo from tracking."""
repo = (
db.query(GithubRepo)
.filter(GithubRepo.account_id == account_id, GithubRepo.id == repo_id)
.first()
)
if not repo:
raise HTTPException(status_code=404, detail="Repo not found")
db.delete(repo)
db.commit()
return {"status": "deleted"}
@router.post("/accounts/{account_id}/repos/{repo_id}/sync")
def trigger_repo_sync(
account_id: int,
repo_id: int,
force_full: bool = False,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
):
"""Manually trigger a sync for a repo."""
from memory.workers.tasks.github import sync_github_repo
repo = (
db.query(GithubRepo)
.filter(GithubRepo.account_id == account_id, GithubRepo.id == repo_id)
.first()
)
if not repo:
raise HTTPException(status_code=404, detail="Repo not found")
task = sync_github_repo.delay(repo_id, force_full=force_full)
return {"task_id": task.id, "status": "scheduled"}

View File

@ -0,0 +1,658 @@
"""API endpoints for Google Drive configuration."""
import json
import os
import secrets
from datetime import datetime, timedelta, timezone
from typing import cast
# Allow Google to return additional scopes (like 'openid') without raising an error
os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from memory.common import settings
from memory.common.db.connection import get_session, make_session
from memory.common.db.models import User
from memory.common.db.models.sources import GoogleAccount, GoogleFolder, GoogleOAuthConfig
from memory.api.auth import get_current_user
router = APIRouter(prefix="/google-drive", tags=["google-drive"])
def get_oauth_config(session: Session) -> GoogleOAuthConfig:
"""Get the OAuth config from database, falling back to env vars if not found."""
config = session.query(GoogleOAuthConfig).filter(GoogleOAuthConfig.name == "default").first()
if config:
return config
# Fall back to environment variables for backwards compatibility
if settings.GOOGLE_CLIENT_ID and settings.GOOGLE_CLIENT_SECRET:
return GoogleOAuthConfig(
name="default",
client_id=settings.GOOGLE_CLIENT_ID,
client_secret=settings.GOOGLE_CLIENT_SECRET,
redirect_uris=[settings.GOOGLE_REDIRECT_URI],
)
raise HTTPException(
status_code=500,
detail="Google OAuth not configured. Upload credentials JSON or set GOOGLE_CLIENT_ID/SECRET.",
)
class FolderCreate(BaseModel):
folder_id: str # Google Drive folder ID
folder_name: str
recursive: bool = True
include_shared: bool = False
tags: list[str] = []
check_interval: int = 60 # Minutes
class FolderUpdate(BaseModel):
folder_name: str | None = None
recursive: bool | None = None
include_shared: bool | None = None
tags: list[str] | None = None
check_interval: int | None = None
active: bool | None = None
class FolderResponse(BaseModel):
id: int
folder_id: str
folder_name: str
folder_path: str | None
recursive: bool
include_shared: bool
tags: list[str]
check_interval: int
last_sync_at: str | None
active: bool
class AccountResponse(BaseModel):
id: int
name: str
email: str
active: bool
last_sync_at: str | None
sync_error: str | None
folders: list[FolderResponse]
# OAuth State storage (temporary, for OAuth flow)
class GoogleOAuthState:
"""In-memory OAuth state storage. In production, use the database."""
_states: dict[str, int] = {} # state -> user_id
@classmethod
def create(cls, user_id: int) -> str:
state = secrets.token_urlsafe(32)
cls._states[state] = user_id
return state
@classmethod
def validate(cls, state: str) -> int | None:
return cls._states.pop(state, None)
class OAuthConfigResponse(BaseModel):
id: int
name: str
client_id: str
project_id: str | None
redirect_uris: list[str]
created_at: str
# Browse endpoint models
class DriveItem(BaseModel):
"""A file or folder in Google Drive."""
id: str
name: str
mime_type: str
is_folder: bool
size: int | None = None
modified_at: str | None = None
class BrowseResponse(BaseModel):
"""Response from browsing a Google Drive folder."""
folder_id: str
folder_name: str
parent_id: str | None = None
items: list[DriveItem]
next_page_token: str | None = None
@router.post("/config")
async def upload_oauth_config(
file: UploadFile,
name: str = "default",
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> OAuthConfigResponse:
"""Upload Google OAuth credentials JSON file."""
if not file.filename or not file.filename.endswith(".json"):
raise HTTPException(status_code=400, detail="File must be a JSON file")
try:
content = await file.read()
json_data = json.loads(content.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
raise HTTPException(status_code=400, detail=f"Invalid JSON file: {e}")
# Check if config already exists
existing = db.query(GoogleOAuthConfig).filter(GoogleOAuthConfig.name == name).first()
if existing:
# Update existing config
creds = json_data.get("web") or json_data.get("installed") or json_data
existing.client_id = creds["client_id"]
existing.client_secret = creds["client_secret"]
existing.project_id = creds.get("project_id")
existing.auth_uri = creds.get("auth_uri", "https://accounts.google.com/o/oauth2/auth")
existing.token_uri = creds.get("token_uri", "https://oauth2.googleapis.com/token")
existing.redirect_uris = creds.get("redirect_uris", [])
existing.javascript_origins = creds.get("javascript_origins", [])
db.commit()
db.refresh(existing)
config = existing
else:
# Create new config
config = GoogleOAuthConfig.from_json(json_data, name=name)
db.add(config)
db.commit()
db.refresh(config)
return OAuthConfigResponse(
id=cast(int, config.id),
name=cast(str, config.name),
client_id=cast(str, config.client_id),
project_id=cast(str | None, config.project_id),
redirect_uris=list(config.redirect_uris or []),
created_at=config.created_at.isoformat() if config.created_at else "",
)
@router.get("/config")
def get_config(
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> OAuthConfigResponse | None:
"""Get current OAuth configuration (without secrets)."""
config = db.query(GoogleOAuthConfig).filter(GoogleOAuthConfig.name == "default").first()
if not config:
return None
return OAuthConfigResponse(
id=cast(int, config.id),
name=cast(str, config.name),
client_id=cast(str, config.client_id),
project_id=cast(str | None, config.project_id),
redirect_uris=list(config.redirect_uris or []),
created_at=config.created_at.isoformat() if config.created_at else "",
)
@router.delete("/config")
def delete_config(
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
):
"""Delete OAuth configuration."""
config = db.query(GoogleOAuthConfig).filter(GoogleOAuthConfig.name == "default").first()
if not config:
raise HTTPException(status_code=404, detail="Config not found")
db.delete(config)
db.commit()
return {"status": "deleted"}
@router.get("/authorize")
def google_authorize(
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> dict:
"""Initiate Google OAuth2 flow. Returns the authorization URL."""
oauth_config = get_oauth_config(db)
from google_auth_oauthlib.flow import Flow
# Determine redirect URI - prefer one from config, fall back to settings
redirect_uri = (
oauth_config.redirect_uris[0]
if oauth_config.redirect_uris
else settings.GOOGLE_REDIRECT_URI
)
flow = Flow.from_client_config(
oauth_config.to_client_config(),
scopes=settings.GOOGLE_SCOPES,
redirect_uri=redirect_uri,
)
# Generate state token with user ID
state = GoogleOAuthState.create(user.id)
authorization_url, _ = flow.authorization_url(
access_type="offline",
include_granted_scopes="true",
state=state,
prompt="consent", # Force consent to get refresh token
)
return {"authorization_url": authorization_url}
@router.get("/callback", response_class=HTMLResponse)
def google_callback(
request: Request,
code: str | None = None,
state: str | None = None,
error: str | None = None,
):
"""Handle Google OAuth2 callback."""
if error:
return HTMLResponse(
content=f"<html><body><h1>Authorization Failed</h1><p>{error}</p></body></html>",
status_code=400,
)
if not code or not state:
return HTMLResponse(
content="<html><body><h1>Missing Parameters</h1></body></html>",
status_code=400,
)
# Validate state
user_id = GoogleOAuthState.validate(state)
if not user_id:
return HTMLResponse(
content="<html><body><h1>Invalid or Expired State</h1></body></html>",
status_code=400,
)
from google_auth_oauthlib.flow import Flow
from googleapiclient.discovery import build
with make_session() as session:
oauth_config = get_oauth_config(session)
redirect_uri = (
oauth_config.redirect_uris[0]
if oauth_config.redirect_uris
else settings.GOOGLE_REDIRECT_URI
)
flow = Flow.from_client_config(
oauth_config.to_client_config(),
scopes=settings.GOOGLE_SCOPES,
redirect_uri=redirect_uri,
)
flow.fetch_token(code=code)
credentials = flow.credentials
# Get user info from Google
service = build("oauth2", "v2", credentials=credentials)
user_info = service.userinfo().get().execute()
# Create or update GoogleAccount
account = (
session.query(GoogleAccount)
.filter(GoogleAccount.email == user_info["email"])
.first()
)
if not account:
account = GoogleAccount(
name=user_info.get("name", user_info["email"]),
email=user_info["email"],
)
session.add(account)
account.access_token = credentials.token
account.refresh_token = credentials.refresh_token
account.token_expires_at = credentials.expiry
account.scopes = list(credentials.scopes or [])
account.active = True
account.sync_error = None
session.commit()
return HTMLResponse(
content="""
<html>
<body>
<h1>Google Drive Connected Successfully!</h1>
<p>You can close this window and configure folders to sync.</p>
<script>window.close();</script>
</body>
</html>
""",
status_code=200,
)
@router.get("/accounts")
def list_accounts(
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> list[AccountResponse]:
"""List all connected Google accounts with their folders."""
accounts = db.query(GoogleAccount).all()
return [
AccountResponse(
id=cast(int, account.id),
name=cast(str, account.name),
email=cast(str, account.email),
active=cast(bool, account.active),
last_sync_at=(
account.last_sync_at.isoformat() if account.last_sync_at else None
),
sync_error=cast(str | None, account.sync_error),
folders=[
FolderResponse(
id=cast(int, folder.id),
folder_id=cast(str, folder.folder_id),
folder_name=cast(str, folder.folder_name),
folder_path=cast(str | None, folder.folder_path),
recursive=cast(bool, folder.recursive),
include_shared=cast(bool, folder.include_shared),
tags=cast(list[str], folder.tags) or [],
check_interval=cast(int, folder.check_interval),
last_sync_at=(
folder.last_sync_at.isoformat() if folder.last_sync_at else None
),
active=cast(bool, folder.active),
)
for folder in account.folders
],
)
for account in accounts
]
@router.get("/accounts/{account_id}/browse")
def browse_folder(
account_id: int,
folder_id: str = "root",
page_size: int = 100,
page_token: str | None = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> BrowseResponse:
"""Browse a Google Drive folder to list its contents.
Special folder_id values:
- "root": User's My Drive root
- "shared": Files shared with the user (Shared with me)
"""
from googleapiclient.discovery import build
from memory.parsers.google_drive import refresh_credentials
account = db.get(GoogleAccount, account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
if not account.active:
raise HTTPException(status_code=400, detail="Account is not active")
# Refresh credentials if needed
credentials = refresh_credentials(account, db)
# Build the Drive service
service = build("drive", "v3", credentials=credentials)
# Handle special folder IDs
if folder_id == "shared":
# List files shared with the user
folder_name = "Shared with me"
parent_id = "root" # Allow navigating back to root
query = "sharedWithMe=true and trashed=false"
elif folder_id == "root":
folder_name = "My Drive"
parent_id = None
query = "'root' in parents and trashed=false"
else:
# Get folder info for a specific folder
try:
folder_info = service.files().get(
fileId=folder_id,
fields="name, parents",
supportsAllDrives=True,
).execute()
folder_name = folder_info.get("name", folder_id)
parents = folder_info.get("parents", [])
parent_id = parents[0] if parents else None
except Exception:
raise HTTPException(status_code=404, detail="Folder not found")
query = f"'{folder_id}' in parents and trashed=false"
try:
response = service.files().list(
q=query,
spaces="drive",
fields="nextPageToken, files(id, name, mimeType, size, modifiedTime)",
pageToken=page_token,
pageSize=page_size,
orderBy="folder,name", # Folders first, then by name
includeItemsFromAllDrives=True,
supportsAllDrives=True,
).execute()
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to list folder: {e}")
# Convert to response items
items = []
# Add "Shared with me" as a virtual folder when at root
if folder_id == "root":
items.append(DriveItem(
id="shared",
name="Shared with me",
mime_type="application/vnd.google-apps.folder",
is_folder=True,
size=None,
modified_at=None,
))
for file in response.get("files", []):
is_folder = file["mimeType"] == "application/vnd.google-apps.folder"
items.append(DriveItem(
id=file["id"],
name=file["name"],
mime_type=file["mimeType"],
is_folder=is_folder,
size=file.get("size"),
modified_at=file.get("modifiedTime"),
))
return BrowseResponse(
folder_id=folder_id,
folder_name=folder_name,
parent_id=parent_id,
items=items,
next_page_token=response.get("nextPageToken"),
)
@router.post("/accounts/{account_id}/folders")
def add_folder(
account_id: int,
folder: FolderCreate,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> FolderResponse:
"""Add a folder to sync for a Google account."""
account = db.get(GoogleAccount, account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
# Check for duplicate
existing = (
db.query(GoogleFolder)
.filter(
GoogleFolder.account_id == account_id,
GoogleFolder.folder_id == folder.folder_id,
)
.first()
)
if existing:
raise HTTPException(status_code=400, detail="Folder already added")
new_folder = GoogleFolder(
account_id=account_id,
folder_id=folder.folder_id,
folder_name=folder.folder_name,
recursive=folder.recursive,
include_shared=folder.include_shared,
tags=folder.tags,
check_interval=folder.check_interval,
)
db.add(new_folder)
db.commit()
db.refresh(new_folder)
return FolderResponse(
id=cast(int, new_folder.id),
folder_id=cast(str, new_folder.folder_id),
folder_name=cast(str, new_folder.folder_name),
folder_path=None,
recursive=cast(bool, new_folder.recursive),
include_shared=cast(bool, new_folder.include_shared),
tags=cast(list[str], new_folder.tags) or [],
check_interval=cast(int, new_folder.check_interval),
last_sync_at=None,
active=cast(bool, new_folder.active),
)
@router.patch("/accounts/{account_id}/folders/{folder_id}")
def update_folder(
account_id: int,
folder_id: int,
updates: FolderUpdate,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> FolderResponse:
"""Update a folder's configuration."""
folder = (
db.query(GoogleFolder)
.filter(
GoogleFolder.account_id == account_id,
GoogleFolder.id == folder_id,
)
.first()
)
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
if updates.folder_name is not None:
folder.folder_name = updates.folder_name
if updates.recursive is not None:
folder.recursive = updates.recursive
if updates.include_shared is not None:
folder.include_shared = updates.include_shared
if updates.tags is not None:
folder.tags = updates.tags
if updates.check_interval is not None:
folder.check_interval = updates.check_interval
if updates.active is not None:
folder.active = updates.active
db.commit()
db.refresh(folder)
return FolderResponse(
id=cast(int, folder.id),
folder_id=cast(str, folder.folder_id),
folder_name=cast(str, folder.folder_name),
folder_path=cast(str | None, folder.folder_path),
recursive=cast(bool, folder.recursive),
include_shared=cast(bool, folder.include_shared),
tags=cast(list[str], folder.tags) or [],
check_interval=cast(int, folder.check_interval),
last_sync_at=(
folder.last_sync_at.isoformat() if folder.last_sync_at else None
),
active=cast(bool, folder.active),
)
@router.delete("/accounts/{account_id}/folders/{folder_id}")
def remove_folder(
account_id: int,
folder_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
):
"""Remove a folder from sync."""
folder = (
db.query(GoogleFolder)
.filter(
GoogleFolder.account_id == account_id,
GoogleFolder.id == folder_id,
)
.first()
)
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
db.delete(folder)
db.commit()
return {"status": "deleted"}
@router.post("/accounts/{account_id}/folders/{folder_id}/sync")
def trigger_sync(
account_id: int,
folder_id: int,
force_full: bool = False,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
):
"""Manually trigger a sync for a folder."""
from memory.workers.tasks.google_drive import sync_google_folder
folder = (
db.query(GoogleFolder)
.filter(
GoogleFolder.account_id == account_id,
GoogleFolder.id == folder_id,
)
.first()
)
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
task = sync_google_folder.delay(folder.id, force_full=force_full)
return {"task_id": task.id, "status": "scheduled"}
@router.delete("/accounts/{account_id}")
def disconnect_account(
account_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_session),
):
"""Disconnect a Google account (removes account and all folders)."""
account = db.get(GoogleAccount, account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
db.delete(account)
db.commit()
return {"status": "disconnected"}

View File

@ -18,6 +18,7 @@ BACKUP_ROOT = "memory.workers.tasks.backup"
GITHUB_ROOT = "memory.workers.tasks.github"
PEOPLE_ROOT = "memory.workers.tasks.people"
PROACTIVE_ROOT = "memory.workers.tasks.proactive"
GOOGLE_ROOT = "memory.workers.tasks.google_drive"
ADD_DISCORD_MESSAGE = f"{DISCORD_ROOT}.add_discord_message"
EDIT_DISCORD_MESSAGE = f"{DISCORD_ROOT}.edit_discord_message"
PROCESS_DISCORD_MESSAGE = f"{DISCORD_ROOT}.process_discord_message"
@ -77,6 +78,10 @@ SYNC_PROFILE_FROM_FILE = f"{PEOPLE_ROOT}.sync_profile_from_file"
# Proactive check-in tasks
EVALUATE_PROACTIVE_CHECKINS = f"{PROACTIVE_ROOT}.evaluate_proactive_checkins"
EXECUTE_PROACTIVE_CHECKIN = f"{PROACTIVE_ROOT}.execute_proactive_checkin"
# Google Drive tasks
SYNC_GOOGLE_FOLDER = f"{GOOGLE_ROOT}.sync_google_folder"
SYNC_GOOGLE_DOC = f"{GOOGLE_ROOT}.sync_google_doc"
SYNC_ALL_GOOGLE_ACCOUNTS = f"{GOOGLE_ROOT}.sync_all_google_accounts"
def get_broker_url() -> str:
@ -136,6 +141,7 @@ app.conf.update(
f"{GITHUB_ROOT}.*": {"queue": f"{settings.CELERY_QUEUE_PREFIX}-github"},
f"{PEOPLE_ROOT}.*": {"queue": f"{settings.CELERY_QUEUE_PREFIX}-people"},
f"{PROACTIVE_ROOT}.*": {"queue": f"{settings.CELERY_QUEUE_PREFIX}-discord"},
f"{GOOGLE_ROOT}.*": {"queue": f"{settings.CELERY_QUEUE_PREFIX}-google"},
},
beat_schedule={
"sync-github-repos-hourly": {
@ -146,6 +152,10 @@ app.conf.update(
"task": EVALUATE_PROACTIVE_CHECKINS,
"schedule": crontab(), # Every minute
},
"sync-google-drive-hourly": {
"task": SYNC_ALL_GOOGLE_ACCOUNTS,
"schedule": crontab(minute=30), # Every hour at :30
},
},
)

View File

@ -22,6 +22,7 @@ from memory.common.db.models.source_items import (
Photo,
MiscDoc,
Note,
GoogleDoc,
MailMessagePayload,
EmailAttachmentPayload,
AgentObservationPayload,
@ -30,6 +31,7 @@ from memory.common.db.models.source_items import (
BookSectionPayload,
NotePayload,
ForumPostPayload,
GoogleDocPayload,
)
from memory.common.db.models.discord import (
DiscordServer,
@ -57,6 +59,9 @@ from memory.common.db.models.sources import (
EmailAccount,
GithubAccount,
GithubRepo,
GoogleOAuthConfig,
GoogleAccount,
GoogleFolder,
)
from memory.common.db.models.users import (
User,
@ -83,6 +88,7 @@ Payload = (
| EmailAttachmentPayload
| MailMessagePayload
| PersonPayload
| GoogleDocPayload
)
__all__ = [
@ -106,6 +112,8 @@ __all__ = [
"Photo",
"MiscDoc",
"Note",
"GoogleDoc",
"GoogleDocPayload",
# Observations
"ObservationContradiction",
"ReactionPattern",
@ -121,6 +129,9 @@ __all__ = [
"EmailAccount",
"GithubAccount",
"GithubRepo",
"GoogleOAuthConfig",
"GoogleAccount",
"GoogleFolder",
"DiscordServer",
"DiscordChannel",
"DiscordUser",

View File

@ -1203,3 +1203,86 @@ class AgentObservation(SourceItem):
@classmethod
def get_collections(cls) -> list[str]:
return ["semantic", "temporal"]
class GoogleDocPayload(SourceItemPayload):
google_file_id: Annotated[str, "Google Drive file ID"]
title: Annotated[str, "Document title"]
original_mime_type: Annotated[str | None, "Original Google/MIME type"]
folder_path: Annotated[str | None, "Path in Google Drive"]
owner: Annotated[str | None, "Document owner email"]
last_modified_by: Annotated[str | None, "Last modifier email"]
google_modified_at: Annotated[str | None, "Last modified time from Google"]
word_count: Annotated[int | None, "Approximate word count"]
class GoogleDoc(SourceItem):
"""Google Drive document (Docs, PDFs, Word files, text)."""
__tablename__ = "google_doc"
id = Column(
BigInteger, ForeignKey("source_item.id", ondelete="CASCADE"), primary_key=True
)
# Google-specific identifiers
google_file_id = Column(Text, nullable=False, unique=True) # Drive file ID
google_modified_at = Column(
DateTime(timezone=True), nullable=True
) # For change detection
# Document metadata
title = Column(Text, nullable=False)
original_mime_type = Column(
Text, nullable=True
) # e.g., "application/vnd.google-apps.document"
folder_id = Column(
BigInteger, ForeignKey("google_folders.id", ondelete="SET NULL"), nullable=True
)
folder_path = Column(Text, nullable=True) # e.g., "My Drive/Work/Projects"
# Authorship tracking
owner = Column(Text, nullable=True) # Email of owner
last_modified_by = Column(Text, nullable=True) # Email of last modifier
# Content stats
word_count = Column(Integer, nullable=True)
# Content hash for change detection
content_hash = Column(Text, nullable=True)
__mapper_args__ = {
"polymorphic_identity": "google_doc",
}
__table_args__ = (
Index("google_doc_file_id_idx", "google_file_id", unique=True),
Index("google_doc_folder_idx", "folder_id"),
Index("google_doc_modified_idx", "google_modified_at"),
Index("google_doc_title_idx", "title"),
)
def as_payload(self) -> GoogleDocPayload:
return GoogleDocPayload(
**super().as_payload(),
google_file_id=cast(str, self.google_file_id),
title=cast(str, self.title),
original_mime_type=cast(str | None, self.original_mime_type),
folder_path=cast(str | None, self.folder_path),
owner=cast(str | None, self.owner),
last_modified_by=cast(str | None, self.last_modified_by),
google_modified_at=(
self.google_modified_at and self.google_modified_at.isoformat()
),
word_count=cast(int | None, self.word_count),
)
def _chunk_contents(self) -> Sequence[extract.DataChunk]:
content = cast(str | None, self.content)
if content:
return extract.extract_text(content, modality="doc")
return []
@classmethod
def get_collections(cls) -> list[str]:
return ["doc"]

View File

@ -226,3 +226,150 @@ class GithubRepo(Base):
@property
def repo_path(self) -> str:
return f"{self.owner}/{self.name}"
class GoogleOAuthConfig(Base):
"""OAuth client configuration for Google APIs (from credentials JSON)."""
__tablename__ = "google_oauth_config"
id = Column(BigInteger, primary_key=True)
name = Column(Text, nullable=False, unique=True, default="default")
client_id = Column(Text, nullable=False)
client_secret = Column(Text, nullable=False)
project_id = Column(Text, nullable=True)
auth_uri = Column(
Text, nullable=False, server_default="https://accounts.google.com/o/oauth2/auth"
)
token_uri = Column(
Text, nullable=False, server_default="https://oauth2.googleapis.com/token"
)
redirect_uris = Column(ARRAY(Text), nullable=False, server_default="{}")
javascript_origins = Column(ARRAY(Text), nullable=False, server_default="{}")
created_at = Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at = Column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
@classmethod
def from_json(cls, json_data: dict, name: str = "default") -> "GoogleOAuthConfig":
"""Create from Google credentials JSON file content."""
# Handle both "web" and "installed" credential types
creds = json_data.get("web") or json_data.get("installed") or json_data
return cls(
name=name,
client_id=creds["client_id"],
client_secret=creds["client_secret"],
project_id=creds.get("project_id"),
auth_uri=creds.get("auth_uri", "https://accounts.google.com/o/oauth2/auth"),
token_uri=creds.get("token_uri", "https://oauth2.googleapis.com/token"),
redirect_uris=creds.get("redirect_uris", []),
javascript_origins=creds.get("javascript_origins", []),
)
def to_client_config(self) -> dict:
"""Convert to format expected by google_auth_oauthlib.flow.Flow."""
return {
"web": {
"client_id": self.client_id,
"client_secret": self.client_secret,
"project_id": self.project_id,
"auth_uri": self.auth_uri,
"token_uri": self.token_uri,
"redirect_uris": list(self.redirect_uris or []),
}
}
class GoogleAccount(Base):
"""Google authentication credentials for Drive API access."""
__tablename__ = "google_accounts"
id = Column(BigInteger, primary_key=True)
name = Column(Text, nullable=False) # Display name
email = Column(Text, nullable=False, unique=True) # Google account email
# OAuth2 tokens
access_token = Column(Text, nullable=True)
refresh_token = Column(Text, nullable=True)
token_expires_at = Column(DateTime(timezone=True), nullable=True)
# Scopes granted
scopes = Column(ARRAY(Text), nullable=False, server_default="{}")
# Status
active = Column(Boolean, nullable=False, server_default="true")
last_sync_at = Column(DateTime(timezone=True), nullable=True)
sync_error = Column(Text, nullable=True) # Last error message if any
created_at = Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at = Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Relationship to folders
folders = relationship(
"GoogleFolder", back_populates="account", cascade="all, delete-orphan"
)
__table_args__ = (
Index("google_accounts_active_idx", "active", "last_sync_at"),
Index("google_accounts_email_idx", "email"),
)
class GoogleFolder(Base):
"""Tracked Google Drive folder configuration."""
__tablename__ = "google_folders"
id = Column(BigInteger, primary_key=True)
account_id = Column(
BigInteger, ForeignKey("google_accounts.id", ondelete="CASCADE"), nullable=False
)
# Folder identification
folder_id = Column(Text, nullable=False) # Google Drive folder ID
folder_name = Column(Text, nullable=False) # Display name
folder_path = Column(Text, nullable=True) # Full path for display
# Sync options
recursive = Column(Boolean, nullable=False, server_default="true") # Include subfolders
include_shared = Column(
Boolean, nullable=False, server_default="false"
) # Include shared files
# File type filters (empty = all text documents)
mime_type_filter = Column(ARRAY(Text), nullable=False, server_default="{}")
# Tags to apply to all documents from this folder
tags = Column(ARRAY(Text), nullable=False, server_default="{}")
# Sync configuration
check_interval = Column(Integer, nullable=False, server_default="60") # Minutes
last_sync_at = Column(DateTime(timezone=True), nullable=True)
# Status
active = Column(Boolean, nullable=False, server_default="true")
created_at = Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at = Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Relationships
account = relationship("GoogleAccount", back_populates="folders")
__table_args__ = (
UniqueConstraint("account_id", "folder_id", name="unique_folder_per_account"),
Index("google_folders_active_idx", "active", "last_sync_at"),
)

View File

@ -244,3 +244,20 @@ S3_BACKUP_ENABLED = boolean_env("S3_BACKUP_ENABLED", bool(BACKUP_ENCRYPTION_KEY)
S3_BACKUP_INTERVAL = int(
os.getenv("S3_BACKUP_INTERVAL", 60 * 60 * 24)
) # Daily by default
# Google OAuth settings
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "")
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "")
GOOGLE_REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI", f"{SERVER_URL}/auth/callback/google")
GOOGLE_SCOPES = [
"https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
]
# Google Drive sync settings
GOOGLE_DRIVE_STORAGE_DIR = pathlib.Path(
os.getenv("GOOGLE_DRIVE_STORAGE_DIR", str(FILE_STORAGE_DIR / "google_drive"))
)
GOOGLE_DRIVE_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
GOOGLE_SYNC_INTERVAL = int(os.getenv("GOOGLE_SYNC_INTERVAL", 60 * 60)) # 1 hour default

View File

@ -0,0 +1,339 @@
"""Google Drive API client for fetching and exporting documents."""
import hashlib
import io
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Generator, TypedDict
from memory.common import settings
logger = logging.getLogger(__name__)
# MIME types we support
SUPPORTED_GOOGLE_MIMES = {
"application/vnd.google-apps.document", # Google Docs
}
SUPPORTED_FILE_MIMES = {
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword",
"text/plain",
"text/markdown",
"text/html",
"text/csv",
}
# Export mappings for Google native formats
EXPORT_MIMES = {
"application/vnd.google-apps.document": "text/plain",
}
@dataclass
class GoogleCredentials:
"""Credentials for Google Drive API access."""
access_token: str
refresh_token: str | None
token_expires_at: datetime | None
scopes: list[str]
class GoogleFileData(TypedDict):
"""Parsed file data ready for storage."""
file_id: str
title: str
mime_type: str
original_mime_type: str
folder_path: str | None
owner: str | None
last_modified_by: str | None
modified_at: datetime | None
created_at: datetime | None
content: str
content_hash: str
size: int
word_count: int
def parse_google_date(date_str: str | None) -> datetime | None:
"""Parse RFC 3339 date string from Google API."""
if not date_str:
return None
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
def compute_content_hash(content: str) -> str:
"""Compute SHA256 hash of content for change detection."""
return hashlib.sha256(content.encode()).hexdigest()
class GoogleDriveClient:
"""Client for Google Drive API."""
def __init__(
self,
credentials: GoogleCredentials,
client_id: str | None = None,
client_secret: str | None = None,
token_uri: str = "https://oauth2.googleapis.com/token",
):
self.credentials = credentials
self._service = None
# Use provided values or fall back to settings
self._client_id = client_id or settings.GOOGLE_CLIENT_ID
self._client_secret = client_secret or settings.GOOGLE_CLIENT_SECRET
self._token_uri = token_uri
def _get_service(self):
"""Lazily build the Drive service."""
if self._service is None:
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
creds = Credentials(
token=self.credentials.access_token,
refresh_token=self.credentials.refresh_token,
token_uri=self._token_uri,
client_id=self._client_id,
client_secret=self._client_secret,
scopes=self.credentials.scopes,
)
self._service = build("drive", "v3", credentials=creds)
return self._service
def list_files_in_folder(
self,
folder_id: str,
recursive: bool = True,
since: datetime | None = None,
page_size: int = 100,
) -> Generator[dict, None, None]:
"""List all supported files in a folder with pagination."""
service = self._get_service()
# Build query for supported file types
all_mimes = SUPPORTED_GOOGLE_MIMES | SUPPORTED_FILE_MIMES
mime_conditions = " or ".join(f"mimeType='{mime}'" for mime in all_mimes)
query_parts = [
f"'{folder_id}' in parents",
"trashed=false",
f"({mime_conditions} or mimeType='application/vnd.google-apps.folder')",
]
if since:
query_parts.append(f"modifiedTime > '{since.isoformat()}'")
query = " and ".join(query_parts)
page_token = None
while True:
response = (
service.files()
.list(
q=query,
spaces="drive",
fields="nextPageToken, files(id, name, mimeType, modifiedTime, createdTime, owners, lastModifyingUser, parents, size)",
pageToken=page_token,
pageSize=page_size,
)
.execute()
)
for file in response.get("files", []):
if file["mimeType"] == "application/vnd.google-apps.folder":
if recursive:
# Recursively list files in subfolder
yield from self.list_files_in_folder(
file["id"], recursive=True, since=since
)
else:
yield file
page_token = response.get("nextPageToken")
if not page_token:
break
def export_file(self, file_id: str, mime_type: str) -> bytes:
"""Export a Google native file to the specified format."""
from googleapiclient.http import MediaIoBaseDownload
service = self._get_service()
if mime_type in SUPPORTED_GOOGLE_MIMES:
# Export Google Docs to text
export_mime = EXPORT_MIMES.get(mime_type, "text/plain")
request = service.files().export_media(fileId=file_id, mimeType=export_mime)
else:
# Download regular files directly
request = service.files().get_media(fileId=file_id)
buffer = io.BytesIO()
downloader = MediaIoBaseDownload(buffer, request)
done = False
while not done:
_, done = downloader.next_chunk()
return buffer.getvalue()
def get_folder_path(self, file_id: str) -> str:
"""Build the full folder path for a file."""
service = self._get_service()
path_parts = []
current_id = file_id
while current_id:
try:
file = (
service.files().get(fileId=current_id, fields="name, parents").execute()
)
path_parts.insert(0, file["name"])
parents = file.get("parents", [])
current_id = parents[0] if parents else None
except Exception:
break
return "/".join(path_parts)
def fetch_file(
self, file_metadata: dict, folder_path: str | None = None
) -> GoogleFileData:
"""Fetch and parse a single file."""
file_id = file_metadata["id"]
mime_type = file_metadata["mimeType"]
logger.info(f"Fetching file: {file_metadata['name']} ({mime_type})")
# Download/export content
content_bytes = self.export_file(file_id, mime_type)
# Handle encoding
try:
content = content_bytes.decode("utf-8")
except UnicodeDecodeError:
content = content_bytes.decode("latin-1")
# For PDFs and Word docs, we need to extract text
if mime_type == "application/pdf":
content = self._extract_pdf_text(content_bytes)
elif mime_type in (
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword",
):
content = self._extract_docx_text(content_bytes)
# Extract owner info
owners = file_metadata.get("owners", [])
owner = owners[0].get("emailAddress") if owners else None
last_modifier = file_metadata.get("lastModifyingUser", {})
last_modified_by = last_modifier.get("emailAddress")
return GoogleFileData(
file_id=file_id,
title=file_metadata["name"],
mime_type=EXPORT_MIMES.get(mime_type, mime_type),
original_mime_type=mime_type,
folder_path=folder_path,
owner=owner,
last_modified_by=last_modified_by,
modified_at=parse_google_date(file_metadata.get("modifiedTime")),
created_at=parse_google_date(file_metadata.get("createdTime")),
content=content,
content_hash=compute_content_hash(content),
size=len(content.encode("utf-8")),
word_count=len(content.split()),
)
def _extract_pdf_text(self, pdf_bytes: bytes) -> str:
"""Extract text from PDF using PyMuPDF."""
try:
import fitz # PyMuPDF
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
text_parts = []
for page in doc:
text_parts.append(page.get_text())
doc.close()
return "\n\n".join(text_parts)
except Exception as e:
logger.warning(f"Failed to extract PDF text: {e}")
return ""
def _extract_docx_text(self, docx_bytes: bytes) -> str:
"""Extract text from Word document."""
try:
import zipfile
from xml.etree import ElementTree
# docx is a zip file containing XML
with zipfile.ZipFile(io.BytesIO(docx_bytes)) as zf:
with zf.open("word/document.xml") as doc_xml:
tree = ElementTree.parse(doc_xml)
root = tree.getroot()
# Word uses namespaces
ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
text_parts = []
for para in root.findall(".//w:p", ns):
para_text = "".join(
node.text or ""
for node in para.findall(".//w:t", ns)
)
if para_text:
text_parts.append(para_text)
return "\n\n".join(text_parts)
except Exception as e:
logger.warning(f"Failed to extract docx text: {e}")
return ""
def _get_oauth_config(session: Any) -> tuple[str, str, str]:
"""Get OAuth client credentials from database or settings."""
from memory.common.db.models.sources import GoogleOAuthConfig
config = session.query(GoogleOAuthConfig).filter(GoogleOAuthConfig.name == "default").first()
if config:
return config.client_id, config.client_secret, config.token_uri
# Fall back to environment variables
return (
settings.GOOGLE_CLIENT_ID,
settings.GOOGLE_CLIENT_SECRET,
"https://oauth2.googleapis.com/token",
)
def refresh_credentials(account: Any, session: Any) -> Any:
"""Refresh OAuth2 credentials if expired."""
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
client_id, client_secret, token_uri = _get_oauth_config(session)
credentials = Credentials(
token=account.access_token,
refresh_token=account.refresh_token,
token_uri=token_uri,
client_id=client_id,
client_secret=client_secret,
scopes=account.scopes or [],
)
if credentials.expired and credentials.refresh_token:
credentials.refresh(Request())
# Update stored tokens
account.access_token = credentials.token
account.token_expires_at = credentials.expiry
session.commit()
return credentials

View File

@ -0,0 +1,305 @@
"""Celery tasks for Google Drive document syncing."""
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, cast
from memory.common import qdrant
from memory.common.celery_app import app
from memory.common.db.connection import make_session
from memory.common.db.models import GoogleDoc
from memory.common.db.models.sources import GoogleAccount, GoogleFolder
from memory.parsers.google_drive import (
GoogleDriveClient,
GoogleCredentials,
GoogleFileData,
refresh_credentials,
_get_oauth_config,
)
from memory.workers.tasks.content_processing import (
create_content_hash,
create_task_result,
process_content_item,
safe_task_execution,
)
logger = logging.getLogger(__name__)
# Task name constants
GOOGLE_ROOT = "memory.workers.tasks.google_drive"
SYNC_GOOGLE_FOLDER = f"{GOOGLE_ROOT}.sync_google_folder"
SYNC_GOOGLE_DOC = f"{GOOGLE_ROOT}.sync_google_doc"
SYNC_ALL_GOOGLE_ACCOUNTS = f"{GOOGLE_ROOT}.sync_all_google_accounts"
def _build_credentials(account: GoogleAccount, session) -> GoogleCredentials:
"""Build credentials from account, refreshing if needed."""
credentials = refresh_credentials(account, session)
return GoogleCredentials(
access_token=credentials.token,
refresh_token=credentials.refresh_token,
token_expires_at=credentials.expiry,
scopes=list(credentials.scopes or []),
)
def _serialize_file_data(data: GoogleFileData) -> dict[str, Any]:
"""Serialize GoogleFileData for Celery task passing."""
return {
**data,
"modified_at": data["modified_at"].isoformat() if data["modified_at"] else None,
"created_at": data["created_at"].isoformat() if data["created_at"] else None,
}
def _deserialize_file_data(data: dict[str, Any]) -> GoogleFileData:
"""Deserialize file data from Celery task."""
from memory.parsers.google_drive import parse_google_date
return GoogleFileData(
file_id=data["file_id"],
title=data["title"],
mime_type=data["mime_type"],
original_mime_type=data["original_mime_type"],
folder_path=data["folder_path"],
owner=data["owner"],
last_modified_by=data["last_modified_by"],
modified_at=parse_google_date(data.get("modified_at")),
created_at=parse_google_date(data.get("created_at")),
content=data["content"],
content_hash=data["content_hash"],
size=data["size"],
word_count=data["word_count"],
)
def _needs_reindex(existing: GoogleDoc, new_data: GoogleFileData) -> bool:
"""Check if an existing document needs reindexing."""
# Compare content hash
if existing.content_hash != new_data["content_hash"]:
return True
# Check if modified time is newer
existing_modified = cast(datetime | None, existing.google_modified_at)
if existing_modified and new_data["modified_at"]:
if new_data["modified_at"] > existing_modified:
return True
return False
def _create_google_doc(
folder: GoogleFolder,
file_data: GoogleFileData,
) -> GoogleDoc:
"""Create a GoogleDoc from parsed file data."""
folder_tags = cast(list[str], folder.tags) or []
return GoogleDoc(
modality="doc",
sha256=create_content_hash(file_data["content"]),
content=file_data["content"],
google_file_id=file_data["file_id"],
title=file_data["title"],
original_mime_type=file_data["original_mime_type"],
folder_id=folder.id,
folder_path=file_data["folder_path"],
owner=file_data["owner"],
last_modified_by=file_data["last_modified_by"],
google_modified_at=file_data["modified_at"],
word_count=file_data["word_count"],
content_hash=file_data["content_hash"],
tags=folder_tags,
size=file_data["size"],
mime_type=file_data["mime_type"],
)
def _update_existing_doc(
session: Any,
existing: GoogleDoc,
folder: GoogleFolder,
file_data: GoogleFileData,
) -> dict[str, Any]:
"""Update an existing GoogleDoc and reindex if content changed."""
if not _needs_reindex(existing, file_data):
return create_task_result(existing, "unchanged")
logger.info(f"Content changed for {file_data['title']}, reindexing")
# Delete old chunks from Qdrant
chunk_ids = [str(c.id) for c in existing.chunks if c.id]
if chunk_ids:
try:
client = qdrant.get_qdrant_client()
qdrant.delete_points(client, cast(str, existing.modality), chunk_ids)
except IOError as e:
logger.error(f"Error deleting chunks: {e}")
# Delete chunks from database
for chunk in existing.chunks:
session.delete(chunk)
# Update the existing item
existing.content = file_data["content"]
existing.sha256 = create_content_hash(file_data["content"])
existing.title = file_data["title"]
existing.google_modified_at = file_data["modified_at"]
existing.last_modified_by = file_data["last_modified_by"]
existing.word_count = file_data["word_count"]
existing.content_hash = file_data["content_hash"]
existing.size = file_data["size"]
existing.folder_path = file_data["folder_path"]
# Update tags
folder_tags = cast(list[str], folder.tags) or []
existing.tags = folder_tags
session.flush()
return process_content_item(existing, session)
@app.task(name=SYNC_GOOGLE_DOC)
@safe_task_execution
def sync_google_doc(
folder_id: int,
file_data_serialized: dict[str, Any],
) -> dict[str, Any]:
"""Sync a single Google Drive document."""
file_data = _deserialize_file_data(file_data_serialized)
logger.info(f"Syncing Google Doc: {file_data['title']}")
with make_session() as session:
folder = session.get(GoogleFolder, folder_id)
if not folder:
return {"status": "error", "error": "Folder not found"}
# Check for existing document by Google file ID
existing = (
session.query(GoogleDoc)
.filter(GoogleDoc.google_file_id == file_data["file_id"])
.first()
)
if existing:
return _update_existing_doc(session, existing, folder, file_data)
# Create new document
google_doc = _create_google_doc(folder, file_data)
return process_content_item(google_doc, session)
@app.task(name=SYNC_GOOGLE_FOLDER)
@safe_task_execution
def sync_google_folder(folder_id: int, force_full: bool = False) -> dict[str, Any]:
"""Sync all documents in a Google Drive folder."""
logger.info(f"Syncing Google folder {folder_id}")
with make_session() as session:
folder = session.get(GoogleFolder, folder_id)
if not folder or not cast(bool, folder.active):
return {"status": "error", "error": "Folder not found or inactive"}
account = folder.account
if not account or not cast(bool, account.active):
return {"status": "error", "error": "Account not found or inactive"}
now = datetime.now(timezone.utc)
last_sync = cast(datetime | None, folder.last_sync_at)
# Check if sync is needed based on interval
if last_sync and not force_full:
check_interval = cast(int, folder.check_interval)
if now - last_sync < timedelta(minutes=check_interval):
return {"status": "skipped_recent_check", "folder_id": folder_id}
# Build credentials (refresh if needed)
try:
credentials = _build_credentials(account, session)
except Exception as e:
account.sync_error = str(e)
account.active = False # Disable until re-auth
session.commit()
return {"status": "error", "error": f"Token refresh failed: {e}"}
# Get OAuth config for client
client_id, client_secret, token_uri = _get_oauth_config(session)
client = GoogleDriveClient(
credentials,
client_id=client_id,
client_secret=client_secret,
token_uri=token_uri,
)
# Determine sync window
since = None if force_full else last_sync
docs_synced = 0
task_ids = []
try:
# Get folder path for context
folder_path = client.get_folder_path(cast(str, folder.folder_id))
for file_meta in client.list_files_in_folder(
cast(str, folder.folder_id),
recursive=cast(bool, folder.recursive),
since=since,
):
try:
file_data = client.fetch_file(file_meta, folder_path)
serialized = _serialize_file_data(file_data)
task = sync_google_doc.delay(folder.id, serialized)
task_ids.append(task.id)
docs_synced += 1
except Exception as e:
logger.error(f"Error fetching file {file_meta.get('name')}: {e}")
continue
# Update sync timestamps
folder.last_sync_at = now
account.last_sync_at = now
account.sync_error = None
session.commit()
except Exception as e:
account.sync_error = str(e)
session.commit()
raise
return {
"status": "completed",
"sync_type": "full" if force_full else "incremental",
"folder_id": folder_id,
"folder_name": folder.folder_name,
"docs_synced": docs_synced,
"task_ids": task_ids,
}
@app.task(name=SYNC_ALL_GOOGLE_ACCOUNTS)
def sync_all_google_accounts(force_full: bool = False) -> list[dict[str, Any]]:
"""Trigger sync for all active Google Drive folders."""
with make_session() as session:
active_folders = (
session.query(GoogleFolder)
.join(GoogleAccount)
.filter(GoogleFolder.active, GoogleAccount.active)
.all()
)
results = [
{
"folder_id": folder.id,
"folder_name": folder.folder_name,
"task_id": sync_google_folder.delay(folder.id, force_full=force_full).id,
}
for folder in active_folders
]
logger.info(
f"Scheduled {'full' if force_full else 'incremental'} sync "
f"for {len(results)} active Google folders"
)
return results