chore: merge origin/main with local test additions
All checks were successful
CI / verify (push) Successful in 10m1s

Resolves conflict in repo.spec.ts by combining getRepoPermission
tests from main with the getFiles and queryFileContent tests from
the local test pass. Adds getRepoPermission to the userRepo.store
spec mocks so the new permission probe added upstream doesn't break
the store tests.
This commit is contained in:
Julien Calixte
2026-06-06 22:17:10 +02:00
17 changed files with 522 additions and 50 deletions

View File

@@ -3,6 +3,7 @@ import { onBeforeMount, ref } from "vue"
import { useRoute, useRouter } from "vue-router" import { useRoute, useRouter } from "vue-router"
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook" import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
import { consumeGithubOAuthReturnPath } from "@/modules/user/service/oauthReturnPath"
import { signIn } from "@/modules/user/service/signIn" import { signIn } from "@/modules/user/service/signIn"
const route = useRoute() const route = useRoute()
@@ -22,8 +23,14 @@ onBeforeMount(async () => {
await saveCredentials(token) await saveCredentials(token)
} }
const returnPath = consumeGithubOAuthReturnPath()
if (!hasError.value && returnPath) {
router.replace(returnPath)
} else {
router.replace({ name: "Home" }) router.replace({ name: "Home" })
} }
}
}) })
</script> </script>

View File

@@ -4,10 +4,10 @@ import { onMounted, ref, watch } from "vue"
const props = defineProps<{ open: boolean }>() const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: "discard"): void discard: []
(e: "overwrite"): void overwrite: []
(e: "cancel"): void cancel: []
(e: "update:open", value: boolean): void "update:open": [value: boolean]
}>() }>()
const dialogRef = ref<HTMLDialogElement | null>(null) const dialogRef = ref<HTMLDialogElement | null>(null)
@@ -17,8 +17,16 @@ const close = () => {
emit("update:open", false) emit("update:open", false)
} }
const choose = (action: "discard" | "overwrite" | "cancel") => { const onDiscard = () => {
emit(action) emit("discard")
close()
}
const onOverwrite = () => {
emit("overwrite")
close()
}
const onCancel = () => {
emit("cancel")
close() close()
} }
@@ -42,7 +50,7 @@ onMounted(() => {
ref="dialogRef" ref="dialogRef"
class="modal" class="modal"
@close="emit('update:open', false)" @close="emit('update:open', false)"
@cancel.prevent="choose('cancel')" @cancel.prevent="onCancel()"
> >
<div class="modal-box"> <div class="modal-box">
<h3 class="text-lg font-bold">GitHub has a newer version of this note</h3> <h3 class="text-lg font-bold">GitHub has a newer version of this note</h3>
@@ -55,28 +63,28 @@ onMounted(() => {
<button <button
type="button" type="button"
class="btn btn-ghost" class="btn btn-ghost"
@click="choose('cancel')" @click="onCancel()"
> >
Cancel Cancel
</button> </button>
<button <button
type="button" type="button"
class="btn btn-warning" class="btn btn-warning"
@click="choose('overwrite')" @click="onOverwrite()"
> >
Save anyway (overwrite) Save anyway (overwrite)
</button> </button>
<button <button
type="button" type="button"
class="btn btn-primary" class="btn btn-primary"
@click="choose('discard')" @click="onDiscard()"
> >
Discard my edits, pull latest Discard my edits, pull latest
</button> </button>
</div> </div>
</div> </div>
<form method="dialog" class="modal-backdrop"> <form method="dialog" class="modal-backdrop">
<button type="submit" @click="choose('cancel')">close</button> <button type="submit" @click="onCancel()">close</button>
</form> </form>
</dialog> </dialog>
</template> </template>

View File

@@ -1,20 +1,34 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useRoute } from "vue-router"
import { GITHUB_OAUTH_RETURN_PATH_KEY } from "@/modules/user/service/oauthReturnPath"
const GITHUB_URL = "https://github.com/login/oauth/authorize" const GITHUB_URL = "https://github.com/login/oauth/authorize"
const CLIENT_ID = "Iv1.12dc43d013ce3623" const CLIENT_ID = "Iv1.12dc43d013ce3623"
const SCOPE = "repo%20workflow" const SCOPE = "repo%20workflow"
const REDIRECT_URI = window.location.origin const REDIRECT_URI = window.location.origin
const route = useRoute()
const url = new URL(GITHUB_URL) const url = new URL(GITHUB_URL)
url.searchParams.set("client_id", CLIENT_ID) url.searchParams.set("client_id", CLIENT_ID)
url.searchParams.set("scope", SCOPE) url.searchParams.set("scope", SCOPE)
url.searchParams.set("redirect_uri", REDIRECT_URI) url.searchParams.set("redirect_uri", REDIRECT_URI)
const href = url.toString() const href = url.toString()
const saveReturnPath = () => {
sessionStorage.setItem(GITHUB_OAUTH_RETURN_PATH_KEY, route.fullPath)
}
</script> </script>
<template> <template>
<a :href="href" class="sign-in-github btn btn-sm btn-primary"> <a
:href="href"
class="sign-in-github btn btn-sm btn-primary"
@click="saveReturnPath"
>
Sign in with Sign in with
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View File

@@ -10,6 +10,7 @@ import {
import { useEditionMode } from "@/hooks/useEditionMode" import { useEditionMode } from "@/hooks/useEditionMode"
import { useFile } from "@/hooks/useFile.hook" import { useFile } from "@/hooks/useFile.hook"
import { useGitHubContent } from "@/hooks/useGitHubContent.hook" import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
import { useImageUpload } from "@/hooks/useImageUpload.hook"
import { useLinks } from "@/hooks/useLinks.hook" import { useLinks } from "@/hooks/useLinks.hook"
import { renderCodeFile } from "@/hooks/useMarkdown.hook" import { renderCodeFile } from "@/hooks/useMarkdown.hook"
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook" import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
@@ -99,6 +100,7 @@ useTitleNotes(repo)
const store = useUserRepoStore() const store = useUserRepoStore()
const hasBacklinks = computed(() => store.userSettings?.backlink) const hasBacklinks = computed(() => store.userSettings?.backlink)
const canPush = computed(() => store.canPush)
const { displayNoteOverlay } = useNoteOverlay(className.value, index) const { displayNoteOverlay } = useNoteOverlay(className.value, index)
const displayedTitle = computed(() => filenameToNoteTitle(props.title ?? "")) const displayedTitle = computed(() => filenameToNoteTitle(props.title ?? ""))
@@ -109,6 +111,34 @@ const { updateFile } = useGitHubContent({
repo: repo.value repo: repo.value
}) })
const { uploadImage } = useImageUpload({
user: user.value,
repo: repo.value,
notePath: path
})
const fileInput = ref<HTMLInputElement | null>(null)
const editKey = ref(0)
const isUploading = ref(false)
const onImagePicked = async (e: Event) => {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ""
if (!file || !path.value) return
isUploading.value = true
try {
const result = await uploadImage(file)
if (!result) return
const trimmed = rawContent.value.replace(/\n+$/, "")
const prefix = trimmed ? `${trimmed}\n\n` : ""
rawContent.value = `${prefix}![](${result.filename})\n`
editKey.value++
} finally {
isUploading.value = false
}
}
const { const {
status: freshnessStatus, status: freshnessStatus,
lastCheckedAt, lastCheckedAt,
@@ -207,10 +237,10 @@ watch(mode, async (newMode) => {
}) })
const onConflictDiscard = async () => { const onConflictDiscard = async () => {
const newRaw = await pullLatest() const { raw } = await pullLatest()
if (newRaw !== null) { if (raw !== null) {
rawContent.value = newRaw rawContent.value = raw
initialRawContent.value = newRaw initialRawContent.value = raw
} }
} }
@@ -240,13 +270,13 @@ const onBadgeClick = async () => {
return return
} }
const newRaw = await pullLatest() const { raw, failureStatus } = await pullLatest()
if (newRaw !== null) { if (raw !== null) {
rawContent.value = newRaw rawContent.value = raw
initialRawContent.value = newRaw initialRawContent.value = raw
return return
} }
if (freshnessStatus.value === "unauthorized") { if (failureStatus === "unauthorized") {
errorMessage("🔐 GitHub auth expired — please sign in again") errorMessage("🔐 GitHub auth expired — please sign in again")
} }
} catch (error) { } catch (error) {
@@ -274,7 +304,7 @@ const onBadgeClick = async () => {
class="action" class="action"
/> />
<button <button
v-if="isMarkdown" v-if="isMarkdown && canPush"
class="action button is-text is-light" class="action button is-text is-light"
:class="{ 'is-link': mode === 'edit' }" :class="{ 'is-link': mode === 'edit' }"
:style="mode === 'edit' ? 'color: var(--color-primary)' : ''" :style="mode === 'edit' ? 'color: var(--color-primary)' : ''"
@@ -323,6 +353,48 @@ const onBadgeClick = async () => {
<path d="M14 4l0 4l-6 0l0 -4" /> <path d="M14 4l0 4l-6 0l0 -4" />
</svg> </svg>
</button> </button>
<button
v-if="isMarkdown && mode === 'edit' && canPush"
class="action button is-text is-light"
:title="isUploading ? 'Uploading…' : 'Upload image'"
:disabled="isUploading"
@click="fileInput?.click()"
>
<span
v-if="isUploading"
class="loading loading-spinner loading-sm"
></span>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-photo-plus"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M15 8h.01" />
<path
d="M12.5 21h-6.5a3 3 0 0 1 -3 -3v-12a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v6.5"
/>
<path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l4 4" />
<path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l2 2" />
<path d="M16 19h6" />
<path d="M19 16v6" />
</svg>
</button>
<input
ref="fileInput"
type="file"
accept="image/*"
class="hidden-input"
@change="onImagePicked"
/>
</div> </div>
<a <a
class="title-stacked-note-link" class="title-stacked-note-link"
@@ -337,7 +409,7 @@ const onBadgeClick = async () => {
</div> </div>
<section class="text-content"> <section class="text-content">
<div v-if="mode === 'edit' && isMarkdown" class="edit"> <div v-if="mode === 'edit' && isMarkdown" class="edit">
<edit-note v-model="rawContent" /> <edit-note :key="editKey" v-model="rawContent" />
</div> </div>
<div <div
v-if="mode === 'read'" v-if="mode === 'read'"
@@ -410,6 +482,10 @@ $border-color: rgba(18, 19, 58, 0.2);
gap: 0.25rem; gap: 0.25rem;
} }
.hidden-input {
display: none;
}
.action { .action {
margin: 0; margin: 0;

View File

@@ -35,7 +35,8 @@ export const useCheckboxCommit = ({
initialContent, initialContent,
initialSha, initialSha,
containerSelector, containerSelector,
debounceMs = 1000 debounceMs = 1000,
enabled = true
}: { }: {
user: string user: string
repo: string repo: string
@@ -44,6 +45,7 @@ export const useCheckboxCommit = ({
initialSha: Ref<string> | string initialSha: Ref<string> | string
containerSelector: string containerSelector: string
debounceMs?: number debounceMs?: number
enabled?: Ref<boolean> | boolean
}) => { }) => {
const { updateFile } = useGitHubContent({ user, repo }) const { updateFile } = useGitHubContent({ user, repo })
@@ -91,6 +93,8 @@ export const useCheckboxCommit = ({
const debouncedCommit = useDebounceFn(commitChanges, debounceMs) const debouncedCommit = useDebounceFn(commitChanges, debounceMs)
const handleCheckboxChange = (event: Event) => { const handleCheckboxChange = (event: Event) => {
if (!toValue(enabled)) return
const target = event.target as HTMLInputElement const target = event.target as HTMLInputElement
if (target.tagName !== "INPUT" || target.type !== "checkbox") { if (target.tagName !== "INPUT" || target.type !== "checkbox") {
@@ -128,8 +132,16 @@ export const useCheckboxCommit = ({
const listenToCheckboxes = () => { const listenToCheckboxes = () => {
removeListeners() removeListeners()
const container = document.querySelector(containerSelector) const container = document.querySelector(containerSelector)
if (container) { if (!container) return
if (toValue(enabled)) {
container.addEventListener("change", handleCheckboxChange) container.addEventListener("change", handleCheckboxChange)
} else {
container
.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')
.forEach((checkbox) => {
checkbox.disabled = true
})
} }
} }

View File

@@ -37,14 +37,22 @@ export const useGitHubContent = ({
} }
} }
const putFile = async ({ const putRaw = async ({
content, contentBase64,
path, path,
sha sha,
message,
successMessage,
conflictMessage,
failureMessage
}: { }: {
content: string contentBase64: string
path: string path: string
sha?: string sha?: string
message: string
successMessage: string
conflictMessage: string
failureMessage: string
}): Promise<{ sha: string | null; conflict: boolean }> => { }): Promise<{ sha: string | null; conflict: boolean }> => {
try { try {
const octokit = await getOctokit() const octokit = await getOctokit()
@@ -55,28 +63,63 @@ export const useGitHubContent = ({
owner: user, owner: user,
repo, repo,
path, path,
message: `Updating ${path} from Remanso`, message,
content: encodeUTF8ToBase64(content), content: contentBase64,
sha sha
} }
) )
confirmMessage("✅ Note saved") confirmMessage(successMessage)
return { sha: response?.data.content?.sha ?? null, conflict: false } return { sha: response?.data.content?.sha ?? null, conflict: false }
} catch (error) { } catch (error) {
const status = (error as { status?: number })?.status const status = (error as { status?: number })?.status
if (status && isConflictStatus(status)) { if (status && isConflictStatus(status)) {
errorMessage("⚠ Conflict: this note changed on GitHub") errorMessage(conflictMessage)
console.warn(error) console.warn(error)
return { sha: null, conflict: true } return { sha: null, conflict: true }
} }
errorMessage("❌ Note could not be saved") errorMessage(failureMessage)
console.warn(error) console.warn(error)
return { sha: null, conflict: false } return { sha: null, conflict: false }
} }
} }
const putFile = async ({
content,
path,
sha
}: {
content: string
path: string
sha?: string
}): Promise<{ sha: string | null; conflict: boolean }> =>
putRaw({
contentBase64: encodeUTF8ToBase64(content),
path,
sha,
message: `Updating ${path} from Remanso`,
successMessage: "✅ Note saved",
conflictMessage: "⚠ Conflict: this note changed on GitHub",
failureMessage: "❌ Note could not be saved"
})
const uploadBinaryFile = async ({
base64,
path
}: {
base64: string
path: string
}): Promise<{ sha: string | null; conflict: boolean }> =>
putRaw({
contentBase64: base64,
path,
message: `Uploading ${path} from Remanso`,
successMessage: "✅ Image uploaded",
conflictMessage: "⚠ A file already exists at this path on GitHub",
failureMessage: "❌ Image could not be uploaded"
})
return { return {
fetchLatestSha, fetchLatestSha,
updateFile: async (props: { updateFile: async (props: {
@@ -85,6 +128,7 @@ export const useGitHubContent = ({
sha: string sha: string
}) => putFile(props), }) => putFile(props),
createFile: async (props: { content: string; path: string }) => createFile: async (props: { content: string; path: string }) =>
putFile(props) putFile(props),
uploadBinaryFile
} }
} }

View File

@@ -0,0 +1,104 @@
import { Ref, toValue } from "vue"
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
import { errorMessage } from "@/utils/notif"
import { uniqueFilename } from "@/utils/uniqueFilename"
const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
const bytes = new Uint8Array(buffer)
const chunkSize = 0x8000
let binary = ""
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize)
binary += String.fromCharCode(...chunk)
}
return btoa(binary)
}
const splitPath = (fullPath: string): { directory: string; filename: string } => {
const lastSlash = fullPath.lastIndexOf("/")
if (lastSlash === -1) return { directory: "", filename: fullPath }
return {
directory: fullPath.slice(0, lastSlash),
filename: fullPath.slice(lastSlash + 1)
}
}
const stripMarkdownExtension = (filename: string): string =>
filename.replace(/\.(md|markdown|mdx)$/i, "")
const extractExtension = (filename: string): string => {
const dot = filename.lastIndexOf(".")
if (dot <= 0) return ".png"
return filename.slice(dot).toLowerCase()
}
export const useImageUpload = ({
user,
repo,
notePath
}: {
user: string
repo: string
notePath: Ref<string | undefined> | string | undefined
}) => {
const store = useUserRepoStore()
const { uploadBinaryFile } = useGitHubContent({ user, repo })
const uploadImage = async (
file: File
): Promise<{ filename: string } | null> => {
const currentNotePath = toValue(notePath)
if (!currentNotePath) {
errorMessage("❌ Image upload failed")
return null
}
try {
const { directory, filename: noteFilename } = splitPath(currentNotePath)
const basename = stripMarkdownExtension(noteFilename)
const extension = extractExtension(file.name)
const existingPaths = store.files
.map((f) => f.path)
.filter((p): p is string => typeof p === "string")
const filename = uniqueFilename({
basename,
extension,
existingPaths,
directory
})
const targetPath = directory ? `${directory}/${filename}` : filename
const buffer = await file.arrayBuffer()
const base64 = arrayBufferToBase64(buffer)
const { sha, conflict } = await uploadBinaryFile({
base64,
path: targetPath
})
if (conflict || !sha) {
return null
}
store.registerUploadedFile({
path: targetPath,
sha,
type: "blob",
size: file.size
})
return { filename }
} catch (error) {
console.warn("image upload failed", error)
errorMessage("❌ Image upload failed")
return null
}
}
return { uploadImage }
}

View File

@@ -77,14 +77,17 @@ export const useNoteFreshness = ({
return { sha: result.sha, failureStatus: null } return { sha: result.sha, failureStatus: null }
} }
const pullLatest = async (): Promise<string | null> => { const pullLatest = async (): Promise<{
if (!path.value) return null raw: string | null
failureStatus: FreshnessStatus | null
}> => {
if (!path.value) return { raw: null, failureStatus: null }
const usedCachedSha = latestSha.value !== null const usedCachedSha = latestSha.value !== null
const { sha: remoteSha, failureStatus } = await resolveRemoteSha(path.value) const { sha: remoteSha, failureStatus } = await resolveRemoteSha(path.value)
if (!remoteSha) { if (!remoteSha) {
console.warn("pullLatest: could not resolve remote sha", { path: path.value }) console.warn("pullLatest: could not resolve remote sha", { path: path.value })
if (failureStatus) status.value = failureStatus if (failureStatus) status.value = failureStatus
return null return { raw: null, failureStatus }
} }
const fileContent = await queryFileContent(user, repo, remoteSha) const fileContent = await queryFileContent(user, repo, remoteSha)
if (!fileContent) { if (!fileContent) {
@@ -96,7 +99,7 @@ export const useNoteFreshness = ({
// Cached SHA may be stale — clear so the next click re-resolves it. // Cached SHA may be stale — clear so the next click re-resolves it.
if (usedCachedSha) latestSha.value = null if (usedCachedSha) latestSha.value = null
status.value = "offline" status.value = "offline"
return null return { raw: null, failureStatus: "offline" }
} }
const { saveCacheNote } = prepareNoteCache(sha.value, path.value) const { saveCacheNote } = prepareNoteCache(sha.value, path.value)
await saveCacheNote(fileContent, { await saveCacheNote(fileContent, {
@@ -108,7 +111,7 @@ export const useNoteFreshness = ({
lastCheckedAt.value = new Date() lastCheckedAt.value = new Date()
status.value = "verified" status.value = "verified"
const { getRawContent } = markdownBuilder(sha.value) const { getRawContent } = markdownBuilder(sha.value)
return getRawContent(fileContent) return { raw: getRawContent(fileContent), failureStatus: null }
} }
return { return {

View File

@@ -1,7 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
const { reposGet } = vi.hoisted(() => ({ reposGet: vi.fn() }))
vi.mock("./octo", () => ({ vi.mock("./octo", () => ({
getOctokit: vi.fn(), getOctokit: vi.fn().mockResolvedValue({
repos: { get: reposGet }
}),
runWithAuthRetry: vi.fn() runWithAuthRetry: vi.fn()
})) }))
@@ -18,12 +22,52 @@ vi.mock("@/modules/note/cache/prepareNoteCache", () => ({
})) }))
import { getOctokit, runWithAuthRetry } from "./octo" import { getOctokit, runWithAuthRetry } from "./octo"
import { getFiles, queryFileContent } from "./repo" import {
getFiles,
getRepoPermission,
queryFileContent
} from "./repo"
const makeOctokitWithRequest = (impl: (route: string, params: unknown) => unknown) => ({ const makeOctokitWithRequest = (impl: (route: string, params: unknown) => unknown) => ({
request: vi.fn(impl) request: vi.fn(impl)
}) })
describe("getRepoPermission", () => {
beforeEach(() => {
reposGet.mockReset()
vi.mocked(getOctokit).mockResolvedValue({
repos: { get: reposGet }
} as never)
})
it("returns true when permissions.push is true", async () => {
reposGet.mockResolvedValue({
data: { permissions: { push: true } }
})
await expect(getRepoPermission("owner", "repo")).resolves.toBe(true)
})
it("returns false when permissions.push is false", async () => {
reposGet.mockResolvedValue({
data: { permissions: { push: false } }
})
await expect(getRepoPermission("owner", "repo")).resolves.toBe(false)
})
it("returns false when permissions is missing (anonymous request)", async () => {
reposGet.mockResolvedValue({
data: {}
})
await expect(getRepoPermission("owner", "repo")).resolves.toBe(false)
})
it("returns false when owner or repo is empty", async () => {
await expect(getRepoPermission("", "repo")).resolves.toBe(false)
await expect(getRepoPermission("owner", "")).resolves.toBe(false)
expect(reposGet).not.toHaveBeenCalled()
})
})
describe("getFiles", () => { describe("getFiles", () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(getOctokit).mockReset() vi.mocked(getOctokit).mockReset()

View File

@@ -4,6 +4,18 @@ import { RepoFile } from "@/modules/repo/interfaces/RepoFile"
import { UserSettings } from "@/modules/repo/interfaces/UserSettings" import { UserSettings } from "@/modules/repo/interfaces/UserSettings"
import { getOctokit, runWithAuthRetry } from "@/modules/repo/services/octo" import { getOctokit, runWithAuthRetry } from "@/modules/repo/services/octo"
export const getRepoPermission = async (
owner: string,
repo: string
): Promise<boolean> => {
if (!owner || !repo) {
return false
}
const octokit = await getOctokit()
const { data } = await octokit.repos.get({ owner, repo })
return data.permissions?.push ?? false
}
export const getFiles = async ( export const getFiles = async (
owner: string, owner: string,
repo: string repo: string

View File

@@ -14,7 +14,8 @@ vi.mock("@/modules/repo/services/repo", () => ({
getFiles: vi.fn().mockResolvedValue([]), getFiles: vi.fn().mockResolvedValue([]),
getMainReadme: vi.fn().mockResolvedValue(null), getMainReadme: vi.fn().mockResolvedValue(null),
getCachedMainReadme: vi.fn().mockResolvedValue(null), getCachedMainReadme: vi.fn().mockResolvedValue(null),
getUserSettingsContent: vi.fn().mockResolvedValue(null) getUserSettingsContent: vi.fn().mockResolvedValue(null),
getRepoPermission: vi.fn().mockResolvedValue(false)
})) }))
vi.mock("@/modules/user/service/signIn", () => ({ vi.mock("@/modules/user/service/signIn", () => ({
@@ -26,6 +27,7 @@ import {
getCachedMainReadme, getCachedMainReadme,
getFiles, getFiles,
getMainReadme, getMainReadme,
getRepoPermission,
getUserSettingsContent getUserSettingsContent
} from "@/modules/repo/services/repo" } from "@/modules/repo/services/repo"
@@ -145,6 +147,7 @@ describe("userRepo store — setUserRepo", () => {
vi.mocked(getMainReadme).mockResolvedValue(null) vi.mocked(getMainReadme).mockResolvedValue(null)
vi.mocked(getCachedMainReadme).mockResolvedValue(null) vi.mocked(getCachedMainReadme).mockResolvedValue(null)
vi.mocked(getUserSettingsContent).mockResolvedValue(null) vi.mocked(getUserSettingsContent).mockResolvedValue(null)
vi.mocked(getRepoPermission).mockResolvedValue(false)
}) })
it("sets user and repo immediately and clears loadError", async () => { it("sets user and repo immediately and clears loadError", async () => {

View File

@@ -10,6 +10,7 @@ import {
getCachedMainReadme, getCachedMainReadme,
getFiles, getFiles,
getMainReadme, getMainReadme,
getRepoPermission,
getUserSettingsContent getUserSettingsContent
} from "@/modules/repo/services/repo" } from "@/modules/repo/services/repo"
import { refreshToken } from "@/modules/user/service/signIn" import { refreshToken } from "@/modules/user/service/signIn"
@@ -24,6 +25,7 @@ interface State {
userSettings?: UserSettings | null userSettings?: UserSettings | null
needToLogin: boolean needToLogin: boolean
loadError: RepoLoadError loadError: RepoLoadError
canPush: boolean
_requestId: number _requestId: number
} }
@@ -46,6 +48,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
userSettings: undefined, userSettings: undefined,
needToLogin: false, needToLogin: false,
loadError: null, loadError: null,
canPush: false,
_requestId: 0 _requestId: 0
}), }),
actions: { actions: {
@@ -78,6 +81,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
this.user = user this.user = user
this.repo = repo this.repo = repo
this.loadError = null this.loadError = null
this.canPush = false
let lsLayout: Partial<UserSettings> = {} let lsLayout: Partial<UserSettings> = {}
try { try {
@@ -120,6 +124,17 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
if (requestId !== this._requestId) return if (requestId !== this._requestId) return
getRepoPermission(user, repo)
.then((canPush) => {
if (requestId !== this._requestId) return
this.canPush = canPush
})
.catch((error) => {
if (requestId !== this._requestId) return
console.warn("getRepoPermission failed", error)
this.canPush = false
})
getFiles(user, repo) getFiles(user, repo)
.then(async (files) => { .then(async (files) => {
if (requestId !== this._requestId) return if (requestId !== this._requestId) return
@@ -230,6 +245,28 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
}) })
this.files = newFiles this.files = newFiles
}, },
registerUploadedFile(file: RepoFile) {
if (!file.path) {
return
}
const savedRepoId = generateId(
DataType.SavedRepo,
`${this.user}-${this.repo}`
)
const newFiles = [
...toRaw(this.files).filter((f) => f.path !== file.path),
toRaw(file)
]
data.update<DataType.SavedRepo, SavedRepo>({
_id: savedRepoId,
$type: DataType.SavedRepo,
repo: this.repo,
user: this.user,
files: newFiles
})
this.files = newFiles
},
resetUserRepo() { resetUserRepo() {
this.user = "" this.user = ""
this.repo = "" this.repo = ""
@@ -239,6 +276,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
resetFiles() { resetFiles() {
this.files = [] this.files = []
this.readme = null this.readme = null
this.canPush = false
}, },
setFontFamily(fontFamily: string) { setFontFamily(fontFamily: string) {
if (!this.userSettings) { if (!this.userSettings) {

View File

@@ -0,0 +1,7 @@
export const GITHUB_OAUTH_RETURN_PATH_KEY = "github-oauth-return-path"
export const consumeGithubOAuthReturnPath = (): string | null => {
const path = sessionStorage.getItem(GITHUB_OAUTH_RETURN_PATH_KEY)
sessionStorage.removeItem(GITHUB_OAUTH_RETURN_PATH_KEY)
return path
}

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest"
import { uniqueFilename } from "./uniqueFilename"
describe("uniqueFilename", () => {
it("returns basename.ext when nothing collides", () => {
expect(
uniqueFilename({
basename: "myfile",
extension: ".png",
existingPaths: ["docs/notes/myfile.md"],
directory: "docs/notes"
})
).toEqual("myfile.png")
})
it("appends -2 on first collision (skips -1)", () => {
expect(
uniqueFilename({
basename: "myfile",
extension: ".png",
existingPaths: ["docs/notes/myfile.png"],
directory: "docs/notes"
})
).toEqual("myfile-2.png")
})
it("appends -3 when both base and -2 exist", () => {
expect(
uniqueFilename({
basename: "myfile",
extension: ".png",
existingPaths: ["docs/notes/myfile.png", "docs/notes/myfile-2.png"],
directory: "docs/notes"
})
).toEqual("myfile-3.png")
})
it("fills gaps when -3 exists but -2 does not", () => {
expect(
uniqueFilename({
basename: "myfile",
extension: ".png",
existingPaths: ["docs/notes/myfile.png", "docs/notes/myfile-3.png"],
directory: "docs/notes"
})
).toEqual("myfile-2.png")
})
it("handles repo-root directory", () => {
expect(
uniqueFilename({
basename: "myfile",
extension: ".jpg",
existingPaths: ["myfile.jpg"],
directory: ""
})
).toEqual("myfile-2.jpg")
})
it("does not collide with a different extension", () => {
expect(
uniqueFilename({
basename: "myfile",
extension: ".png",
existingPaths: ["docs/myfile.jpg"],
directory: "docs"
})
).toEqual("myfile.png")
})
})

View File

@@ -0,0 +1,21 @@
export const uniqueFilename = ({
basename,
extension,
existingPaths,
directory
}: {
basename: string
extension: string
existingPaths: string[]
directory: string
}): string => {
const prefix = directory ? `${directory}/` : ""
const candidate = (n: number) =>
n === 1 ? `${basename}${extension}` : `${basename}-${n}${extension}`
const taken = new Set(existingPaths)
let n = 1
while (taken.has(`${prefix}${candidate(n)}`)) {
n = n === 1 ? 2 : n + 1
}
return candidate(n)
}

View File

@@ -9,6 +9,7 @@ import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hoo
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache" import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
import EditNote from "@/modules/note/components/EditNote.vue" import EditNote from "@/modules/note/components/EditNote.vue"
import { useFolderNotes } from "@/modules/note/hooks/useFolderNotes" import { useFolderNotes } from "@/modules/note/hooks/useFolderNotes"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8" import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
import { confirmMessage, errorMessage } from "@/utils/notif" import { confirmMessage, errorMessage } from "@/utils/notif"
import { extractYouTubeId } from "@/utils/youtube" import { extractYouTubeId } from "@/utils/youtube"
@@ -76,6 +77,9 @@ const { createFile } = useGitHubContent({
const hasTodayNote = computed(() => content.value.includes(today)) const hasTodayNote = computed(() => content.value.includes(today))
const store = useUserRepoStore()
const canPush = computed(() => store.canPush)
watch(mode, async (newMode) => { watch(mode, async (newMode) => {
if (newMode === "read" && newContent.value.trim() !== initialContent) { if (newMode === "read" && newContent.value.trim() !== initialContent) {
const content = `# ${new Date().toLocaleDateString()}\n\n${ const content = `# ${new Date().toLocaleDateString()}\n\n${
@@ -114,13 +118,15 @@ watch(mode, async (newMode) => {
<h3 class="subtitle">Inbox</h3> <h3 class="subtitle">Inbox</h3>
<div class="actions"> <div class="actions">
<button <button
v-if="!hasTodayNote" v-if="!hasTodayNote && canPush"
class="btn btn-secondary" class="btn btn-secondary"
@click="toggleMode" @click="toggleMode"
> >
new fleeting note new fleeting note
</button> </button>
<button class="btn btn-outline" @click="handleYouTube">YouTube</button> <button v-if="canPush" class="btn btn-outline" @click="handleYouTube">
YouTube
</button>
</div> </div>
<div v-if="mode === 'edit'"> <div v-if="mode === 'edit'">

View File

@@ -26,9 +26,10 @@ const todoNote = computed(() =>
const sha = computed(() => todoNote.value?.sha ?? "") const sha = computed(() => todoNote.value?.sha ?? "")
const path = computed(() => todoNote.value?.path) const path = computed(() => todoNote.value?.path)
const canPush = computed(() => store.canPush)
const { toHTML } = markdownBuilder(repo) const { toHTML } = markdownBuilder(repo)
// Setup checkbox commit handler
const { pendingContent, syncContent, listenToCheckboxes, hasPendingChanges } = const { pendingContent, syncContent, listenToCheckboxes, hasPendingChanges } =
useCheckboxCommit({ useCheckboxCommit({
user: props.user, user: props.user,
@@ -37,7 +38,8 @@ const { pendingContent, syncContent, listenToCheckboxes, hasPendingChanges } =
initialContent: "", initialContent: "",
initialSha: sha, initialSha: sha,
containerSelector: ".todo-notes .note-display", containerSelector: ".todo-notes .note-display",
debounceMs: 1000 debounceMs: 1000,
enabled: canPush
}) })
// Render pending content to HTML for display // Render pending content to HTML for display
@@ -64,9 +66,9 @@ watch(
{ immediate: true } { immediate: true }
) )
// Setup checkbox listeners when content renders // Setup checkbox listeners when content renders or canPush changes
watch( watch(
renderedContent, [renderedContent, canPush],
async () => { async () => {
await nextTick() await nextTick()
listenToCheckboxes() listenToCheckboxes()