diff --git a/src/lib/client/ui/modal/SaveTargetModal.svelte b/src/lib/client/ui/modal/SaveTargetModal.svelte index c400b21..b9dcea7 100644 --- a/src/lib/client/ui/modal/SaveTargetModal.svelte +++ b/src/lib/client/ui/modal/SaveTargetModal.svelte @@ -3,12 +3,25 @@ import { X, User, GitBranch } from 'lucide-svelte'; export let open = false; + export let mode: 'save' | 'delete' = 'save'; const dispatch = createEventDispatcher<{ select: 'user' | 'base'; cancel: void; }>(); + $: title = mode === 'save' ? 'Where to save?' : 'Where to delete from?'; + $: userLabel = mode === 'save' ? 'Personal Override' : 'Remove Personal Override'; + $: userDescription = + mode === 'save' + ? "Save locally only. Changes won't sync upstream and stay on this machine." + : 'Remove your local override. The base database version will apply.'; + $: baseLabel = mode === 'save' ? 'Contribute to Database' : 'Delete from Database'; + $: baseDescription = + mode === 'save' + ? "Add to base operations. You'll need to commit and push manually." + : "Create a delete operation. You'll need to commit and push manually."; + function handleSelect(layer: 'user' | 'base') { dispatch('select', layer); } @@ -54,7 +67,7 @@ >
-

Where to save?

+

{title}

-
Personal Override
+
{userLabel}
- Save locally only. Changes won't sync upstream and stay on this machine. + {userDescription}
@@ -91,9 +104,9 @@
-
Contribute to Database
+
{baseLabel}
- Add to base operations. You'll need to commit and push manually. + {baseDescription}
diff --git a/src/lib/server/pcd/writer.ts b/src/lib/server/pcd/writer.ts index 8e33e9d..1ae0aa4 100644 --- a/src/lib/server/pcd/writer.ts +++ b/src/lib/server/pcd/writer.ts @@ -147,6 +147,74 @@ function generateMetadataHeader(metadata: OperationMetadata): string { return lines.join('\n') + '\n\n'; } +/** + * Parse metadata from an operation file + */ +async function parseOperationMetadata(filepath: string): Promise { + try { + const content = await Deno.readTextFile(filepath); + const operationMatch = content.match(/^-- @operation:\s*(\w+)/m); + const entityMatch = content.match(/^-- @entity:\s*(\w+)/m); + const nameMatch = content.match(/^-- @name:\s*(.+)$/m); + const previousNameMatch = content.match(/^-- @previous_name:\s*(.+)$/m); + + if (!operationMatch || !entityMatch || !nameMatch) { + return null; + } + + return { + operation: operationMatch[1] as OperationType, + entity: entityMatch[1], + name: nameMatch[1].trim(), + previousName: previousNameMatch?.[1]?.trim() + }; + } catch { + return null; + } +} + +/** + * Find and remove a matching create operation for a delete + * Returns true if a create was found and removed (no delete needed) + */ +async function cancelOutCreate( + targetDir: string, + metadata: OperationMetadata +): Promise { + if (metadata.operation !== 'delete') { + return false; + } + + try { + for await (const entry of Deno.readDir(targetDir)) { + if (!entry.isFile || !entry.name.endsWith('.sql')) continue; + + const filepath = `${targetDir}/${entry.name}`; + const fileMeta = await parseOperationMetadata(filepath); + + // Check if this is a create operation for the same entity/name + if ( + fileMeta && + fileMeta.operation === 'create' && + fileMeta.entity === metadata.entity && + fileMeta.name === metadata.name + ) { + // Remove the create file - it cancels out with the delete + await Deno.remove(filepath); + await logger.info('Cancelled out create operation with delete', { + source: 'PCDWriter', + meta: { filepath, entity: metadata.entity, name: metadata.name } + }); + return true; + } + } + } catch { + // Directory doesn't exist or other error - proceed with normal write + } + + return false; +} + /** * Write operations to a PCD layer * @@ -176,6 +244,13 @@ export async function writeOperation(options: WriteOptions): Promise status.getBranch(this.repoPath); - status = (): Promise => status.getStatus(this.repoPath); + getBranches = () => status.getBranches(this.repoPath); + status = (options?: status.GetStatusOptions): Promise => status.getStatus(this.repoPath, options); checkForUpdates = (): Promise => status.checkForUpdates(this.repoPath); getLastPushed = () => status.getLastPushed(this.repoPath); diff --git a/src/lib/server/utils/git/index.ts b/src/lib/server/utils/git/index.ts index f75b793..69941f0 100644 --- a/src/lib/server/utils/git/index.ts +++ b/src/lib/server/utils/git/index.ts @@ -4,6 +4,7 @@ export { Git } from './Git.ts'; export * from './types.ts'; +export type { GetStatusOptions } from './status.ts'; // Direct function exports -export { clone } from './repo.ts'; +export { clone, getRepoInfo } from './repo.ts'; diff --git a/src/lib/server/utils/git/ops.ts b/src/lib/server/utils/git/ops.ts index 413b16a..4d004a1 100644 --- a/src/lib/server/utils/git/ops.ts +++ b/src/lib/server/utils/git/ops.ts @@ -69,24 +69,23 @@ export async function getUncommittedOps(repoPath: string): Promise { let maxNum = 0; - const opsPath = `${repoPath}/ops`; - try { - for await (const entry of Deno.readDir(opsPath)) { - if (entry.isFile && entry.name.endsWith('.sql')) { - const match = entry.name.match(/^(\d+)\./); - if (match) { - const num = parseInt(match[1], 10); - if (num > maxNum) maxNum = num; - } - } + // Use git ls-tree to only count committed files + const output = await execGitSafe(['ls-tree', '--name-only', 'HEAD', 'ops/'], repoPath); + if (!output) return maxNum; + + for (const filename of output.split('\n')) { + if (!filename.trim() || !filename.endsWith('.sql')) continue; + const basename = filename.replace('ops/', ''); + const match = basename.match(/^(\d+)\./); + if (match) { + const num = parseInt(match[1], 10); + if (num > maxNum) maxNum = num; } - } catch { - // Directory might not exist } return maxNum; @@ -112,6 +111,11 @@ export async function discardOps(repoPath: string, filepaths: string[]): Promise /** * Add operation files: pull, renumber if needed, commit, push + * + * TODO: This functionality needs to be redesigned. The current approach of + * pull -> renumber -> commit -> push has race conditions and edge cases that + * can leave files in inconsistent states if any step fails mid-way. We also want to better + * choose when / how renumbering happens (e.g., only when there are conflicts). */ export async function addOps( repoPath: string, diff --git a/src/lib/server/utils/git/repo.ts b/src/lib/server/utils/git/repo.ts index 4b12e80..0c42e47 100644 --- a/src/lib/server/utils/git/repo.ts +++ b/src/lib/server/utils/git/repo.ts @@ -3,6 +3,7 @@ */ import { execGit, execGitSafe } from './exec.ts'; +import type { RepoInfo } from './types.ts'; /** * Validate that a repository URL is accessible and detect if it's private @@ -147,7 +148,11 @@ export async function resetToRemote(repoPath: string): Promise { */ export async function stage(repoPath: string, filepaths: string[]): Promise { for (const filepath of filepaths) { - await execGit(['add', filepath], repoPath); + // Convert to relative path if it starts with the repo path + const relativePath = filepath.startsWith(repoPath + '/') + ? filepath.slice(repoPath.length + 1) + : filepath; + await execGit(['add', relativePath], repoPath); } } @@ -157,3 +162,56 @@ export async function stage(repoPath: string, filepaths: string[]): Promise { await execGit(['commit', '-m', message], repoPath); } + +/** + * Get repository info from GitHub API + */ +export async function getRepoInfo( + repositoryUrl: string, + personalAccessToken?: string | null +): Promise { + const githubPattern = /^https:\/\/github\.com\/([\w-]+)\/([\w-]+)\/?$/; + const normalizedUrl = repositoryUrl.replace(/\.git$/, ''); + const match = normalizedUrl.match(githubPattern); + + if (!match) { + return null; + } + + const [, owner, repo] = match; + const apiUrl = `https://api.github.com/repos/${owner}/${repo}`; + + const headers: Record = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'Profilarr' + }; + + if (personalAccessToken) { + headers['Authorization'] = `Bearer ${personalAccessToken}`; + } + + try { + const response = await globalThis.fetch(apiUrl, { headers }); + + if (!response.ok) { + return null; + } + + const data = await response.json(); + + return { + owner: data.owner.login, + repo: data.name, + description: data.description, + stars: data.stargazers_count, + forks: data.forks_count, + openIssues: data.open_issues_count, + ownerAvatarUrl: data.owner.avatar_url, + ownerType: data.owner.type, + htmlUrl: data.html_url + }; + } catch { + return null; + } +} diff --git a/src/lib/server/utils/git/status.ts b/src/lib/server/utils/git/status.ts index 80792ce..0f0b8ae 100644 --- a/src/lib/server/utils/git/status.ts +++ b/src/lib/server/utils/git/status.ts @@ -13,14 +13,21 @@ export async function getBranch(repoPath: string): Promise { return await execGit(['branch', '--show-current'], repoPath); } +export interface GetStatusOptions { + /** Whether to fetch from remote first (slower but accurate ahead/behind) */ + fetch?: boolean; +} + /** * Get full repository status */ -export async function getStatus(repoPath: string): Promise { +export async function getStatus(repoPath: string, options: GetStatusOptions = {}): Promise { const branch = await getBranch(repoPath); - // Fetch to get accurate ahead/behind - await fetch(repoPath); + // Optionally fetch to get accurate ahead/behind + if (options.fetch) { + await fetch(repoPath); + } // Get ahead/behind let ahead = 0; @@ -109,3 +116,19 @@ export async function getLastPushed(repoPath: string): Promise { const output = await execGitSafe(['log', '-1', '--format=%cI', `origin/${branch}`], repoPath); return output || null; } + +/** + * Get all local and remote branches + */ +export async function getBranches(repoPath: string): Promise { + // Get all branches (local and remote tracking) + const output = await execGit(['branch', '-a', '--format=%(refname:short)'], repoPath); + const branches = output + .split('\n') + .map((b) => b.trim()) + .filter((b) => b && !b.includes('HEAD')) + .map((b) => b.replace(/^origin\//, '')) + .filter((b, i, arr) => arr.indexOf(b) === i); // dedupe + + return branches; +} diff --git a/src/lib/server/utils/git/types.ts b/src/lib/server/utils/git/types.ts index 49ca2f1..1bcaf45 100644 --- a/src/lib/server/utils/git/types.ts +++ b/src/lib/server/utils/git/types.ts @@ -33,3 +33,15 @@ export interface UpdateInfo { latestRemoteCommit: string; currentLocalCommit: string; } + +export interface RepoInfo { + owner: string; + repo: string; + description: string | null; + stars: number; + forks: number; + openIssues: number; + ownerAvatarUrl: string; + ownerType: 'User' | 'Organization'; + htmlUrl: string; +} diff --git a/src/routes/databases/[id]/+layout.server.ts b/src/routes/databases/[id]/+layout.server.ts new file mode 100644 index 0000000..33325ec --- /dev/null +++ b/src/routes/databases/[id]/+layout.server.ts @@ -0,0 +1,21 @@ +import { error } from '@sveltejs/kit'; +import type { LayoutServerLoad } from './$types'; +import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts'; + +export const load: LayoutServerLoad = ({ params }) => { + const id = parseInt(params.id || '', 10); + + if (isNaN(id)) { + error(400, 'Invalid database ID'); + } + + const database = databaseInstancesQueries.getById(id); + + if (!database) { + error(404, 'Database not found'); + } + + return { + database + }; +}; diff --git a/src/routes/databases/[id]/+layout.svelte b/src/routes/databases/[id]/+layout.svelte new file mode 100644 index 0000000..0b6513b --- /dev/null +++ b/src/routes/databases/[id]/+layout.svelte @@ -0,0 +1,38 @@ + + +
+ + +
diff --git a/src/routes/databases/[id]/+page.server.ts b/src/routes/databases/[id]/+page.server.ts index 6d85842..e1302ab 100644 --- a/src/routes/databases/[id]/+page.server.ts +++ b/src/routes/databases/[id]/+page.server.ts @@ -1,21 +1,13 @@ -import { error } from '@sveltejs/kit'; -import type { ServerLoad } from '@sveltejs/kit'; -import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts'; +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; -export const load: ServerLoad = ({ params }) => { - const id = parseInt(params.id || '', 10); +export const load: PageServerLoad = async ({ params, parent }) => { + const { database } = await parent(); - if (isNaN(id)) { - error(400, 'Invalid database ID'); + // Dev databases (with PAT) go to changes, others go to sync + if (database.personal_access_token) { + redirect(302, `/databases/${params.id}/changes`); + } else { + redirect(302, `/databases/${params.id}/sync`); } - - const database = databaseInstancesQueries.getById(id); - - if (!database) { - error(404, 'Database not found'); - } - - return { - database - }; }; diff --git a/src/routes/databases/[id]/+page.svelte b/src/routes/databases/[id]/+page.svelte index 44cf492..1ef213e 100644 --- a/src/routes/databases/[id]/+page.svelte +++ b/src/routes/databases/[id]/+page.svelte @@ -1,19 +1 @@ - - - - {data.database.name} - Profilarr - - - - - - + diff --git a/src/routes/databases/[id]/changes/+page.server.ts b/src/routes/databases/[id]/changes/+page.server.ts new file mode 100644 index 0000000..e870509 --- /dev/null +++ b/src/routes/databases/[id]/changes/+page.server.ts @@ -0,0 +1,108 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts'; +import { Git, getRepoInfo } from '$utils/git/index.ts'; +import { logger } from '$logger/logger.ts'; + +export const load: PageServerLoad = async ({ parent }) => { + const { database } = await parent(); + + if (!database.personal_access_token) { + error(403, 'Changes page requires a personal access token'); + } + + const git = new Git(database.local_path); + + const [status, uncommittedOps, lastPushed, branches, repoInfo] = await Promise.all([ + git.status(), + git.getUncommittedOps(), + git.getLastPushed(), + git.getBranches(), + getRepoInfo(database.repository_url, database.personal_access_token) + ]); + + return { + status, + uncommittedOps, + lastPushed, + branches, + repoInfo + }; +}; + +export const actions: Actions = { + discard: async ({ request, params }) => { + const id = parseInt(params.id || '', 10); + const database = databaseInstancesQueries.getById(id); + + if (!database) { + return { success: false, error: 'Database not found' }; + } + + const formData = await request.formData(); + const files = formData.getAll('files') as string[]; + + if (files.length === 0) { + return { success: false, error: 'No files selected' }; + } + + const git = new Git(database.local_path); + await git.discardOps(files); + + return { success: true }; + }, + + add: async ({ request, params }) => { + const id = parseInt(params.id || '', 10); + const database = databaseInstancesQueries.getById(id); + + if (!database) { + return { success: false, error: 'Database not found' }; + } + + const formData = await request.formData(); + const files = formData.getAll('files') as string[]; + const message = formData.get('message') as string; + + const git = new Git(database.local_path); + const result = await git.addOps(files, message); + + if (result.success) { + await logger.info('Changes committed and pushed', { + source: 'changes', + meta: { databaseId: id, files: files.length, message } + }); + } else { + await logger.error('Failed to add changes', { + source: 'changes', + meta: { databaseId: id, error: result.error, files } + }); + } + + return result; + }, + + checkout: async ({ request, params }) => { + const id = parseInt(params.id || '', 10); + const database = databaseInstancesQueries.getById(id); + + if (!database) { + return { success: false, error: 'Database not found' }; + } + + const formData = await request.formData(); + const branch = formData.get('branch') as string; + + if (!branch) { + return { success: false, error: 'No branch specified' }; + } + + try { + const git = new Git(database.local_path); + await git.checkout(branch); + return { success: true }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : 'Failed to switch branch' }; + } + } +}; diff --git a/src/routes/databases/[id]/changes/+page.svelte b/src/routes/databases/[id]/changes/+page.svelte new file mode 100644 index 0000000..b6b010d --- /dev/null +++ b/src/routes/databases/[id]/changes/+page.svelte @@ -0,0 +1,265 @@ + + + + Changes - {data.database.name} - Profilarr + + +
+ + + + + + + {#if data.uncommittedOps.length === 0} +
+

No uncommitted changes

+
+ {:else} +
+ + + + + + + + + + + + {#each data.uncommittedOps as op} + toggleRow(op.filepath)} + > + + + + + + + {/each} + +
+ + + Operation + + Entity + + Name + + File +
+
+ {#if selected.has(op.filepath)} + + {/if} +
+
+ + {formatOperation(op.operation)} + + + {op.entity || '-'} + + {#if op.previousName && op.previousName !== op.name} + {op.previousName} + → + {op.name || '-'} + {:else} + {op.name || '-'} + {/if} + + + {op.filename} + +
+
+ {/if} +
diff --git a/src/routes/databases/[id]/changes/components/ChangesActionsBar.svelte b/src/routes/databases/[id]/changes/components/ChangesActionsBar.svelte new file mode 100644 index 0000000..2f76a87 --- /dev/null +++ b/src/routes/databases/[id]/changes/components/ChangesActionsBar.svelte @@ -0,0 +1,70 @@ + + + +
+
+ +
+
+ + + + +
+ {#if !selectedCount} + Select changes to add + {:else if !commitMessage.trim()} + Enter a commit message + {:else} + Add {selectedCount} change{selectedCount === 1 ? '' : 's'} + {/if} +
+
+
+
+ + + + +
+ {#if canDiscard} + Discard {selectedCount} change{selectedCount === 1 ? '' : 's'} + {:else} + Select changes to discard + {/if} +
+
+
+
+
diff --git a/src/routes/databases/[id]/changes/components/StatusCard.svelte b/src/routes/databases/[id]/changes/components/StatusCard.svelte new file mode 100644 index 0000000..13b422b --- /dev/null +++ b/src/routes/databases/[id]/changes/components/StatusCard.svelte @@ -0,0 +1,169 @@ + + + + +
+
+ +
+ {#if repoInfo} + {repoInfo.owner} +
+
+ + {repoInfo.owner}/{repoInfo.repo} + + + + +
+
+ + + {repoInfo.stars.toLocaleString()} + + + + {repoInfo.forks.toLocaleString()} + + + + {repoInfo.openIssues.toLocaleString()} + +
+
+ {/if} +
+ + +
+ + {#if status.ahead > 0} +
+ + {status.ahead} +
+ {/if} + + {#if status.behind > 0} +
+ + {status.behind} +
+ {/if} + + +
+ + + {#if branchDropdownOpen} +
+ {#each branches as branch} + + {/each} +
+ {/if} +
+ + + + + Commits + +
+
+
diff --git a/src/routes/databases/[id]/edit/+layout@.svelte b/src/routes/databases/[id]/edit/+layout@.svelte new file mode 100644 index 0000000..af1293c --- /dev/null +++ b/src/routes/databases/[id]/edit/+layout@.svelte @@ -0,0 +1,5 @@ + + + diff --git a/src/routes/databases/[id]/sync/+page.svelte b/src/routes/databases/[id]/sync/+page.svelte new file mode 100644 index 0000000..4e94d8a --- /dev/null +++ b/src/routes/databases/[id]/sync/+page.svelte @@ -0,0 +1,7 @@ + + +
+ Sync configuration coming soon +
diff --git a/src/routes/delay-profiles/[databaseId]/[id]/+page.server.ts b/src/routes/delay-profiles/[databaseId]/[id]/+page.server.ts index a0e3842..7196675 100644 --- a/src/routes/delay-profiles/[databaseId]/[id]/+page.server.ts +++ b/src/routes/delay-profiles/[databaseId]/[id]/+page.server.ts @@ -5,6 +5,7 @@ import { canWriteToBase } from '$pcd/writer.ts'; import * as delayProfileQueries from '$pcd/queries/delayProfiles/index.ts'; import type { OperationLayer } from '$pcd/writer.ts'; import type { PreferredProtocol } from '$pcd/queries/delayProfiles/index.ts'; +import { logger } from '$logger/logger.ts'; export const load: ServerLoad = async ({ params }) => { const { databaseId, id } = params; @@ -127,7 +128,7 @@ export const actions: Actions = { throw redirect(303, `/delay-profiles/${databaseId}`); }, - delete: async ({ params }) => { + delete: async ({ request, params }) => { const { databaseId, id } = params; if (!databaseId || !id) { @@ -152,11 +153,29 @@ export const actions: Actions = { return fail(404, { error: 'Delay profile not found' }); } - // Delete always goes to user layer (can't remove base ops) + const formData = await request.formData(); + const layerFromForm = formData.get('layer'); + const layer = (layerFromForm as OperationLayer) || 'user'; + + await logger.debug('Delete action received', { + source: 'DelayProfileDelete', + meta: { + profileId, + profileName: current.name, + layerFromForm, + layerUsed: layer + } + }); + + // Check layer permission + if (layer === 'base' && !canWriteToBase(currentDatabaseId)) { + return fail(403, { error: 'Cannot write to base layer without personal access token' }); + } + const result = await delayProfileQueries.remove({ databaseId: currentDatabaseId, cache, - layer: 'user', + layer, current }); diff --git a/src/routes/delay-profiles/[databaseId]/components/DelayProfileForm.svelte b/src/routes/delay-profiles/[databaseId]/components/DelayProfileForm.svelte index 16fe216..834f03d 100644 --- a/src/routes/delay-profiles/[databaseId]/components/DelayProfileForm.svelte +++ b/src/routes/delay-profiles/[databaseId]/components/DelayProfileForm.svelte @@ -1,8 +1,8 @@
@@ -292,7 +314,7 @@ {#if mode === 'edit'}
- -{#if mode === 'edit'} - { - showDeleteModal = false; - deleteFormElement?.requestSubmit(); - }} - on:cancel={() => (showDeleteModal = false)} - /> -{/if} - {#if canWriteToBase} (showSaveTargetModal = false)} /> + + + (showDeleteTargetModal = false)} + /> {/if} diff --git a/src/routes/delay-profiles/[databaseId]/new/+page.server.ts b/src/routes/delay-profiles/[databaseId]/new/+page.server.ts index 3b16435..2f72fec 100644 --- a/src/routes/delay-profiles/[databaseId]/new/+page.server.ts +++ b/src/routes/delay-profiles/[databaseId]/new/+page.server.ts @@ -5,6 +5,7 @@ import { canWriteToBase } from '$pcd/writer.ts'; import * as delayProfileQueries from '$pcd/queries/delayProfiles/index.ts'; import type { OperationLayer } from '$pcd/writer.ts'; import type { PreferredProtocol } from '$pcd/queries/delayProfiles/index.ts'; +import { logger } from '$logger/logger.ts'; export const load: ServerLoad = ({ params }) => { const { databaseId } = params; @@ -58,7 +59,17 @@ export const actions: Actions = { const bypassIfHighestQuality = formData.get('bypassIfHighestQuality') === 'true'; const bypassIfAboveCfScore = formData.get('bypassIfAboveCfScore') === 'true'; const minimumCfScore = parseInt(formData.get('minimumCfScore') as string, 10) || 0; - const layer = (formData.get('layer') as OperationLayer) || 'user'; + const layerFromForm = formData.get('layer'); + const layer = (layerFromForm as OperationLayer) || 'user'; + + await logger.debug('Create action received', { + source: 'DelayProfileCreate', + meta: { + profileName: name, + layerFromForm, + layerUsed: layer + } + }); // Validate if (!name?.trim()) {