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

View File

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

View File

@@ -1,20 +1,34 @@
<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 CLIENT_ID = "Iv1.12dc43d013ce3623"
const SCOPE = "repo%20workflow"
const REDIRECT_URI = window.location.origin
const route = useRoute()
const url = new URL(GITHUB_URL)
url.searchParams.set("client_id", CLIENT_ID)
url.searchParams.set("scope", SCOPE)
url.searchParams.set("redirect_uri", REDIRECT_URI)
const href = url.toString()
const saveReturnPath = () => {
sessionStorage.setItem(GITHUB_OAUTH_RETURN_PATH_KEY, route.fullPath)
}
</script>
<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
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -10,6 +10,7 @@ import {
import { useEditionMode } from "@/hooks/useEditionMode"
import { useFile } from "@/hooks/useFile.hook"
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
import { useImageUpload } from "@/hooks/useImageUpload.hook"
import { useLinks } from "@/hooks/useLinks.hook"
import { renderCodeFile } from "@/hooks/useMarkdown.hook"
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
@@ -99,6 +100,7 @@ useTitleNotes(repo)
const store = useUserRepoStore()
const hasBacklinks = computed(() => store.userSettings?.backlink)
const canPush = computed(() => store.canPush)
const { displayNoteOverlay } = useNoteOverlay(className.value, index)
const displayedTitle = computed(() => filenameToNoteTitle(props.title ?? ""))
@@ -109,6 +111,34 @@ const { updateFile } = useGitHubContent({
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 {
status: freshnessStatus,
lastCheckedAt,
@@ -207,10 +237,10 @@ watch(mode, async (newMode) => {
})
const onConflictDiscard = async () => {
const newRaw = await pullLatest()
if (newRaw !== null) {
rawContent.value = newRaw
initialRawContent.value = newRaw
const { raw } = await pullLatest()
if (raw !== null) {
rawContent.value = raw
initialRawContent.value = raw
}
}
@@ -240,13 +270,13 @@ const onBadgeClick = async () => {
return
}
const newRaw = await pullLatest()
if (newRaw !== null) {
rawContent.value = newRaw
initialRawContent.value = newRaw
const { raw, failureStatus } = await pullLatest()
if (raw !== null) {
rawContent.value = raw
initialRawContent.value = raw
return
}
if (freshnessStatus.value === "unauthorized") {
if (failureStatus === "unauthorized") {
errorMessage("🔐 GitHub auth expired — please sign in again")
}
} catch (error) {
@@ -274,7 +304,7 @@ const onBadgeClick = async () => {
class="action"
/>
<button
v-if="isMarkdown"
v-if="isMarkdown && canPush"
class="action button is-text is-light"
:class="{ 'is-link': mode === 'edit' }"
:style="mode === 'edit' ? 'color: var(--color-primary)' : ''"
@@ -323,6 +353,48 @@ const onBadgeClick = async () => {
<path d="M14 4l0 4l-6 0l0 -4" />
</svg>
</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>
<a
class="title-stacked-note-link"
@@ -337,7 +409,7 @@ const onBadgeClick = async () => {
</div>
<section class="text-content">
<div v-if="mode === 'edit' && isMarkdown" class="edit">
<edit-note v-model="rawContent" />
<edit-note :key="editKey" v-model="rawContent" />
</div>
<div
v-if="mode === 'read'"
@@ -410,6 +482,10 @@ $border-color: rgba(18, 19, 58, 0.2);
gap: 0.25rem;
}
.hidden-input {
display: none;
}
.action {
margin: 0;

View File

@@ -35,7 +35,8 @@ export const useCheckboxCommit = ({
initialContent,
initialSha,
containerSelector,
debounceMs = 1000
debounceMs = 1000,
enabled = true
}: {
user: string
repo: string
@@ -44,6 +45,7 @@ export const useCheckboxCommit = ({
initialSha: Ref<string> | string
containerSelector: string
debounceMs?: number
enabled?: Ref<boolean> | boolean
}) => {
const { updateFile } = useGitHubContent({ user, repo })
@@ -91,6 +93,8 @@ export const useCheckboxCommit = ({
const debouncedCommit = useDebounceFn(commitChanges, debounceMs)
const handleCheckboxChange = (event: Event) => {
if (!toValue(enabled)) return
const target = event.target as HTMLInputElement
if (target.tagName !== "INPUT" || target.type !== "checkbox") {
@@ -128,8 +132,16 @@ export const useCheckboxCommit = ({
const listenToCheckboxes = () => {
removeListeners()
const container = document.querySelector(containerSelector)
if (container) {
if (!container) return
if (toValue(enabled)) {
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 ({
content,
const putRaw = async ({
contentBase64,
path,
sha
sha,
message,
successMessage,
conflictMessage,
failureMessage
}: {
content: string
contentBase64: string
path: string
sha?: string
message: string
successMessage: string
conflictMessage: string
failureMessage: string
}): Promise<{ sha: string | null; conflict: boolean }> => {
try {
const octokit = await getOctokit()
@@ -55,28 +63,63 @@ export const useGitHubContent = ({
owner: user,
repo,
path,
message: `Updating ${path} from Remanso`,
content: encodeUTF8ToBase64(content),
message,
content: contentBase64,
sha
}
)
confirmMessage("✅ Note saved")
confirmMessage(successMessage)
return { sha: response?.data.content?.sha ?? null, conflict: false }
} catch (error) {
const status = (error as { status?: number })?.status
if (status && isConflictStatus(status)) {
errorMessage("⚠ Conflict: this note changed on GitHub")
errorMessage(conflictMessage)
console.warn(error)
return { sha: null, conflict: true }
}
errorMessage("❌ Note could not be saved")
errorMessage(failureMessage)
console.warn(error)
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 {
fetchLatestSha,
updateFile: async (props: {
@@ -85,6 +128,7 @@ export const useGitHubContent = ({
sha: string
}) => putFile(props),
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 }
}
const pullLatest = async (): Promise<string | null> => {
if (!path.value) return null
const pullLatest = async (): Promise<{
raw: string | null
failureStatus: FreshnessStatus | null
}> => {
if (!path.value) return { raw: null, failureStatus: null }
const usedCachedSha = latestSha.value !== null
const { sha: remoteSha, failureStatus } = await resolveRemoteSha(path.value)
if (!remoteSha) {
console.warn("pullLatest: could not resolve remote sha", { path: path.value })
if (failureStatus) status.value = failureStatus
return null
return { raw: null, failureStatus }
}
const fileContent = await queryFileContent(user, repo, remoteSha)
if (!fileContent) {
@@ -96,7 +99,7 @@ export const useNoteFreshness = ({
// Cached SHA may be stale — clear so the next click re-resolves it.
if (usedCachedSha) latestSha.value = null
status.value = "offline"
return null
return { raw: null, failureStatus: "offline" }
}
const { saveCacheNote } = prepareNoteCache(sha.value, path.value)
await saveCacheNote(fileContent, {
@@ -108,7 +111,7 @@ export const useNoteFreshness = ({
lastCheckedAt.value = new Date()
status.value = "verified"
const { getRawContent } = markdownBuilder(sha.value)
return getRawContent(fileContent)
return { raw: getRawContent(fileContent), failureStatus: null }
}
return {

View File

@@ -1,7 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
const { reposGet } = vi.hoisted(() => ({ reposGet: vi.fn() }))
vi.mock("./octo", () => ({
getOctokit: vi.fn(),
getOctokit: vi.fn().mockResolvedValue({
repos: { get: reposGet }
}),
runWithAuthRetry: vi.fn()
}))
@@ -18,12 +22,52 @@ vi.mock("@/modules/note/cache/prepareNoteCache", () => ({
}))
import { getOctokit, runWithAuthRetry } from "./octo"
import { getFiles, queryFileContent } from "./repo"
import {
getFiles,
getRepoPermission,
queryFileContent
} from "./repo"
const makeOctokitWithRequest = (impl: (route: string, params: unknown) => unknown) => ({
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", () => {
beforeEach(() => {
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 { 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 (
owner: string,
repo: string

View File

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

View File

@@ -10,6 +10,7 @@ import {
getCachedMainReadme,
getFiles,
getMainReadme,
getRepoPermission,
getUserSettingsContent
} from "@/modules/repo/services/repo"
import { refreshToken } from "@/modules/user/service/signIn"
@@ -24,6 +25,7 @@ interface State {
userSettings?: UserSettings | null
needToLogin: boolean
loadError: RepoLoadError
canPush: boolean
_requestId: number
}
@@ -46,6 +48,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
userSettings: undefined,
needToLogin: false,
loadError: null,
canPush: false,
_requestId: 0
}),
actions: {
@@ -78,6 +81,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
this.user = user
this.repo = repo
this.loadError = null
this.canPush = false
let lsLayout: Partial<UserSettings> = {}
try {
@@ -120,6 +124,17 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
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)
.then(async (files) => {
if (requestId !== this._requestId) return
@@ -230,6 +245,28 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
})
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() {
this.user = ""
this.repo = ""
@@ -239,6 +276,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
resetFiles() {
this.files = []
this.readme = null
this.canPush = false
},
setFontFamily(fontFamily: string) {
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 EditNote from "@/modules/note/components/EditNote.vue"
import { useFolderNotes } from "@/modules/note/hooks/useFolderNotes"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
import { confirmMessage, errorMessage } from "@/utils/notif"
import { extractYouTubeId } from "@/utils/youtube"
@@ -76,6 +77,9 @@ const { createFile } = useGitHubContent({
const hasTodayNote = computed(() => content.value.includes(today))
const store = useUserRepoStore()
const canPush = computed(() => store.canPush)
watch(mode, async (newMode) => {
if (newMode === "read" && newContent.value.trim() !== initialContent) {
const content = `# ${new Date().toLocaleDateString()}\n\n${
@@ -114,13 +118,15 @@ watch(mode, async (newMode) => {
<h3 class="subtitle">Inbox</h3>
<div class="actions">
<button
v-if="!hasTodayNote"
v-if="!hasTodayNote && canPush"
class="btn btn-secondary"
@click="toggleMode"
>
new fleeting note
</button>
<button class="btn btn-outline" @click="handleYouTube">YouTube</button>
<button v-if="canPush" class="btn btn-outline" @click="handleYouTube">
YouTube
</button>
</div>
<div v-if="mode === 'edit'">

View File

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