Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

- [ ] `npm run lint` passes
- [ ] `npm run typecheck` passes
- [ ] `npm test` passes
- [ ] `npm run build` passes
- [ ] Migration generated and committed (if `server/database/schema.ts` changed)
- [ ] Docs updated (if behavior or configuration changed)
5 changes: 4 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ concurrency:

jobs:
check:
name: Lint, Typecheck and Build
name: Lint, Typecheck, Test and Build
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
Expand All @@ -35,5 +35,8 @@ jobs:
- name: Typecheck
run: npm run typecheck

- name: Test
run: npm test

- name: Build
run: npm run build
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ scripts/ Standalone CLI scripts (e.g. OTP setup)
```bash
npm run lint
npm run typecheck
npm test
npm run build
```

Expand All @@ -49,7 +50,8 @@ Fix Upload Size Check

- TypeScript everywhere; avoid `any` (the linter enforces this).
- Match the style of the surrounding code. Run `npm run lint:fix` to auto-fix what ESLint can.
- Put types and constants used by both the client and the server in `shared/`.
- Put types, constants and validation schemas used by both the client and the server in `shared/`.
- Add or update tests for behavior changes (`test/unit/` for pure logic, `test/e2e/` for API behavior).

## License

Expand Down
11 changes: 11 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,20 @@ Run these before opening a pull request (CI runs them too):
```bash
npm run lint # ESLint (use lint:fix to auto-fix)
npm run typecheck # vue-tsc
npm test # unit tests and API tests against a production build
npm run build # production build
```

### Tests

- `test/unit/` covers pure logic (schemas, file type detection, login throttling). Run just these with `npm run test:unit`.
- `test/e2e/` builds the app, starts it on a temporary database and exercises the API.
- Search tests are skipped unless a Meilisearch instance is available:

```bash
MEILISEARCH_TEST_HOST=http://localhost:7700 MEILISEARCH_TEST_KEY=dev-master-key npm test
```

## Database Migrations

Migrations in `server/database/migrations/` run automatically on startup.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ The app reads secrets from `.env` (created by `setup.sh`).
| `DATABASE_PATH` | Yes | SQLite database file path. |
| `FILES_PATH` | Yes | Attachments storage path. |
| `MEILISEARCH_HOST` | Yes | Meilisearch HTTP endpoint. |
| `SEARCH_REINDEX` | No | Set to `true` to rebuild the search index on startup, e.g. after deleting `data/meili/`. Remove it afterwards. |

## Data Location

Expand All @@ -102,7 +103,7 @@ tar czf onearctic-backup-$(date +%F).tar.gz data/
docker compose start app
```

To restore, stop the services, replace `data/` with the contents of the archive, and start them again. Keep `data/meili/` in the backup too: without it, search only finds notes edited after the restore.
To restore, stop the services, replace `data/` with the contents of the archive, and start them again. If you leave `data/meili/` out of the backup, start the app once with `SEARCH_REINDEX=true` after restoring to rebuild search.

## Update

Expand Down
13 changes: 3 additions & 10 deletions app/components/attachment/FileList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,8 @@ const { dragOverIdx, onDragStart, onDragOver, onDrop, onDragEnd, startTouchDrag
const nextItems = items.map((item, position) => ({ ...item, position }))
localAttachments.value = nextItems
if (!props.noteId) return
try {
await reorderAttachments(props.noteId, nextItems.map((att) => att.id))
} catch (e) {
showSnackbar(getErrorMessage(e, 'Reorder Failed'), 'error')
localAttachments.value = [...props.attachments]
}
const ok = await reorderAttachments(props.noteId, nextItems.map((att) => att.id))
if (!ok) localAttachments.value = [...props.attachments]
},
})

Expand Down Expand Up @@ -173,12 +169,9 @@ const saveRename = async (att: Attachment) => {
showSnackbar('Filename is Required', 'error')
return
}
try {
await renameAttachment(props.noteId, att.id, nextName)
if (await renameAttachment(props.noteId, att.id, nextName)) {
showSnackbar('Attachment Renamed')
cancelRename()
} catch (e) {
showSnackbar(getErrorMessage(e, 'Rename Failed'), 'error')
}
}

Expand Down
2 changes: 1 addition & 1 deletion app/components/attachment/ImageGrid.vue
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ const fallbackToOriginal = (e: Event, attId: string) => {
const props = defineProps<{
attachments: Attachment[]
deletable?: boolean
onDelete?: (id: string) => Promise<void>
onDelete?: (id: string) => Promise<boolean | undefined>
maxVisible?: number
}>()
const emit = defineEmits<{ delete: [id: string] }>()
Expand Down
3 changes: 1 addition & 2 deletions app/components/label/Manager.vue
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,7 @@ const editName = ref('')
const addLabel = async () => {
const name = newName.value.trim()
if (!name || name.length > MAX_LABEL_LENGTH) return
await createLabel(name)
newName.value = ''
if (await createLabel(name)) newName.value = ''
}

const startEdit = (label: Label) => {
Expand Down
1 change: 1 addition & 0 deletions app/components/label/Picker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ const toggle = (id: string) => {
const addLabel = async () => {
if (!newName.value.trim()) return
const created = await createLabel(newName.value.trim())
if (!created) return
newName.value = ''
// Auto-select the newly created label
localSelected.value = [...localSelected.value, created.id]
Expand Down
2 changes: 1 addition & 1 deletion app/components/note/EditorContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ const toggleType = () => { localType.value = localType.value === 'text' ? 'check
const onLabel = (ids: string[]) => { localLabelIds.value = ids; emitUpdate() }

const deleteAtt = async (attId: string) => {
if (props.note) await deleteAttachment(props.note.id, attId)
if (props.note) return deleteAttachment(props.note.id, attId)
}

defineExpose({ showTitleError: () => { titleError.value = true } })
Expand Down
17 changes: 0 additions & 17 deletions app/composables/useAuthRequest.ts

This file was deleted.

52 changes: 33 additions & 19 deletions app/composables/useLabels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,14 @@ export const useLabels = () => {
// transferred to the client, preventing hydration mismatches.
const labels = useState<Label[]>('labels', () => [])

// During SSR, forwards the original request's cookies so auth works on hard refresh.
const apiFetch = useRequestFetch()
const apiFetch = useNuxtApp().$api
const { subscribe } = useEventSource()
const realtimeBound = useState<boolean>('labels-realtime-bound', () => false)
const { consume: consumeLocalLabelEvent, run: runLocalLabelMutation } =
useLocalRealtimeEvents('local-label-events')

const fetchLabels = async () => {
const data = await apiFetch<{ labels: Label[] }>('/api/labels')
labels.value = data.labels
labels.value = await apiFetch<Label[]>('/api/labels')
}

if (import.meta.client && !realtimeBound.value) {
Expand All @@ -31,23 +29,30 @@ export const useLabels = () => {
event.type !== 'reconnected' &&
(consumeLocalLabelEvent(eventKey) || (labelId ? consumeLocalLabelEvent(event.type) : false))
) return
fetchLabels().catch(() => {})
fetchLabels().catch((error) => console.warn('[realtime] Failed to refresh labels:', error))
}
})
}

/** Resolves to the new label, or undefined on failure (an error snackbar is shown). */
const createLabel = async (name: string) => {
const label = await runLocalLabelMutation('label.created', () =>
apiFetch<Label>('/api/labels', { method: 'POST', body: { name } })
const label = await withErrorSnackbar('Failed to Create Label', () =>
runLocalLabelMutation('label.created', () =>
apiFetch<Label>('/api/labels', { method: 'POST', body: { name } })
)
)
labels.value.push(label)
if (label) labels.value.push(label)
return label
}

/** Resolves to the renamed label, or undefined on failure (an error snackbar is shown). */
const renameLabel = async (id: string, name: string) => {
const label = await runLocalLabelMutation(`label.updated:${id}`, () =>
apiFetch<Label>(labelUrl(id), { method: 'PUT', body: { name } })
const label = await withErrorSnackbar('Failed to Rename Label', () =>
runLocalLabelMutation(`label.updated:${id}`, () =>
apiFetch<Label>(labelUrl(id), { method: 'PUT', body: { name } })
)
)
if (!label) return undefined
const idx = labels.value.findIndex((l) => l.id === id)
if (idx !== -1) labels.value[idx] = label
const notes = useState<Note[]>('notes')
Expand All @@ -61,9 +66,11 @@ export const useLabels = () => {
}

const deleteLabel = async (id: string) => {
await runLocalLabelMutation(`label.deleted:${id}`, () =>
apiFetch(labelUrl(id), { method: 'DELETE' })
)
const ok = await withErrorSnackbar('Failed to Delete Label', async () => {
await runLocalLabelMutation(`label.deleted:${id}`, () => apiFetch(labelUrl(id), { method: 'DELETE' }))
return true
})
if (!ok) return false
labels.value = labels.value.filter((l) => l.id !== id)
// Remove label from all notes in-memory so UI updates immediately
const notes = useState<Note[]>('notes')
Expand All @@ -73,16 +80,23 @@ export const useLabels = () => {
labels: note.labels.filter((l) => l.id !== id),
}))
}
return true
}

const reorderLabels = async (ordered: Label[]) => {
const previous = labels.value
labels.value = ordered
await runLocalLabelMutation('label.reordered', () =>
apiFetch('/api/labels/reorder', {
method: 'PUT',
body: { order: ordered.map((l, i) => ({ id: l.id, position: i })) },
})
)
const ok = await withErrorSnackbar('Failed to Reorder Labels', async () => {
await runLocalLabelMutation('label.reordered', () =>
apiFetch('/api/labels/reorder', {
method: 'PUT',
body: { order: ordered.map((l, i) => ({ id: l.id, position: i })) },
})
)
return true
})
if (!ok) labels.value = previous
return Boolean(ok)
}

return {
Expand Down
8 changes: 5 additions & 3 deletions app/composables/useLightbox.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// Stored outside useState so it survives across composable calls without needing Nuxt context
let _onDeleteCallback: ((id: string) => Promise<void>) | null = null
// Resolving to false means the delete failed and the image stays in the lightbox
type DeleteCallback = (id: string) => Promise<boolean | undefined>
let _onDeleteCallback: DeleteCallback | null = null

export function useLightbox() {
const lightboxImages = useState<string[]>('lightbox-images', () => [])
Expand All @@ -12,7 +14,7 @@ export function useLightbox() {
images: string[],
startIndex = 0,
attachmentIds: string[] = [],
onDelete?: (id: string) => Promise<void>,
onDelete?: DeleteCallback,
names: string[] = [],
) => {
lightboxImages.value = images
Expand All @@ -38,7 +40,7 @@ export function useLightbox() {
const deleteCurrentImage = async () => {
const id = lightboxAttachmentIds.value[lightboxIndex.value]
if (!id || !_onDeleteCallback) return
await _onDeleteCallback(id)
if ((await _onDeleteCallback(id)) === false) return
// Remove deleted image from lightbox arrays
const imgs = [...lightboxImages.value]
const ids = [...lightboxAttachmentIds.value]
Expand Down
Loading
Loading