Initial commit of version 0.2.0

This commit is contained in:
Elijah 2026-07-19 18:37:14 -07:00
parent 86fc06b877
commit dfb04462f3
108 changed files with 16167 additions and 80 deletions

17
app/src/lib/color.ts Normal file
View file

@ -0,0 +1,17 @@
export function normalizeColor(value?: string | null): string {
const normalized = (value ?? '').trim().replace(/^#/, '').toUpperCase();
return /^[0-9A-F]{6}$/.test(normalized) ? normalized : 'D9DEE8';
}
export function textColor(background?: string | null): string {
const color = normalizeColor(background);
const red = Number.parseInt(color.slice(0, 2), 16);
const green = Number.parseInt(color.slice(2, 4), 16);
const blue = Number.parseInt(color.slice(4, 6), 16);
const luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255;
return luminance > 0.58 ? '#11151d' : '#ffffff';
}
export function cssColor(value?: string | null): string {
return `#${normalizeColor(value)}`;
}

View file

@ -0,0 +1,16 @@
import { invoke } from '@tauri-apps/api/core';
export async function loadPassword(server: string, username: string): Promise<string> {
if (!server || !username || !('__TAURI_INTERNALS__' in window)) return '';
return (await invoke<string | null>('get_credential', { server, username })) ?? '';
}
export async function savePassword(server: string, username: string, password: string): Promise<void> {
if (!('__TAURI_INTERNALS__' in window)) return;
await invoke('set_credential', { server, username, password });
}
export async function removePassword(server: string, username: string): Promise<void> {
if (!('__TAURI_INTERNALS__' in window)) return;
await invoke('delete_credential', { server, username });
}

View file

@ -0,0 +1,99 @@
import { describe, expect, it, vi } from 'vitest';
import { cardUpdateFrom, DeckApiClient, type FetchLike } from './deck-api';
import type { Card } from './types';
const options = { serverUrl: 'https://cloud.example.test', username: 'admin', appPassword: 'secret' };
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
describe('DeckApiClient', () => {
it('sends required authentication headers', async () => {
const fetchImpl = vi.fn<FetchLike>().mockImplementation(async () => json([]));
const client = await DeckApiClient.connect(options, fetchImpl);
await client.getBoards();
const headers = new Headers(fetchImpl.mock.calls[1][1]?.headers);
expect(headers.get('OCS-APIRequest')).toBe('true');
expect(headers.get('Authorization')).toMatch(/^Basic /);
});
it('falls back to the rewrite-friendly API path', async () => {
const fetchImpl = vi.fn<FetchLike>().mockImplementation(async (input) =>
String(input).includes('/index.php/') ? json({ message: 'not found' }, 404) : json([])
);
const client = await DeckApiClient.connect(options, fetchImpl);
expect(client.apiBaseUrl.toString()).toBe('https://cloud.example.test/apps/deck/api/v1.0/');
});
it('preserves complete card fields and normalizes object owners', () => {
const card: Card = {
id: 1,
stackId: 2,
title: 'Task',
description: 'Details',
type: 'plain',
owner: { uid: 'admin' },
order: 3,
done: null,
color: '#aabbcc'
};
expect(cardUpdateFrom(card, { done: '2026-01-01T00:00:00Z' })).toMatchObject({
owner: 'admin',
description: 'Details',
order: 3,
done: '2026-01-01T00:00:00Z',
color: 'AABBCC'
});
});
it('can explicitly clear description, due date, and completion', () => {
const card: Card = {
id: 1, stackId: 2, title: 'Task', description: 'Old', type: 'plain', owner: 'admin', order: 0,
duedate: '2026-01-01T00:00:00Z', done: '2026-01-01T00:00:00Z', color: 'ABCDEF'
};
expect(cardUpdateFrom(card, { description: null, duedate: null, done: null })).toMatchObject({
description: null, duedate: null, done: null, color: 'ABCDEF'
});
});
it('retries cross-stack movement through the destination route when Deck ignores the body stack', async () => {
let puts = 0;
const fetchImpl = vi.fn<FetchLike>().mockImplementation(async (input, init) => {
const url = String(input);
if (url.endsWith('/boards')) return json([]);
if (init?.method === 'PUT') {
puts += 1;
return json(true);
}
if (url.includes('/stacks/8/')) {
return puts > 1
? json({ id: 11, stackId: 8, title: 'Moved', owner: 'admin' })
: json({ status: 404 }, 404);
}
return json({ id: 11, stackId: 7, title: 'Not moved', owner: 'admin' });
});
const client = await DeckApiClient.connect(options, fetchImpl);
const moved = await client.reorderCard(2, 7, 11, 8, 0);
expect(moved.stackId).toBe(8);
expect(puts).toBe(2);
});
it('reorders a stack through the same OCS endpoint as the Nextcloud web client and verifies persistence', async () => {
const fetchImpl = vi.fn<FetchLike>().mockImplementation(async (input) => {
const url = String(input);
if (url.endsWith('/boards')) return json([]);
if (url.includes('/ocs/v2.php/')) return json({ ocs: { meta: { status: 'ok' }, data: [] } });
return json([
{ id: 8, boardId: 2, title: 'Earlier', order: 0, cards: [] },
{ id: 7, boardId: 2, title: 'Today', order: 1, cards: [] }
]);
});
const client = await DeckApiClient.connect(options, fetchImpl);
await client.reorderStack(2, 7, 1);
const [url, init] = fetchImpl.mock.calls[1];
expect(String(url)).toBe('https://cloud.example.test/ocs/v2.php/apps/deck/api/v1.0/stacks/7/reorder?format=json');
expect(init?.method).toBe('PUT');
expect(JSON.parse(String(init?.body))).toEqual({ order: 1, boardId: 2 });
expect(String(fetchImpl.mock.calls[2][0])).toContain('/boards/2/stacks');
});
});

212
app/src/lib/deck-api.ts Normal file
View file

@ -0,0 +1,212 @@
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { normalizeColor } from './color';
import type { Board, Card, CardDraft, DeckUser, Stack } from './types';
export const DOCUMENTED_API_PATH = 'index.php/apps/deck/api/v1.0/';
export const REWRITTEN_API_PATH = 'apps/deck/api/v1.0/';
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
export class DeckApiError extends Error {
constructor(
public readonly status: number,
public readonly url: string,
public readonly responseBody: string
) {
super(`Deck request failed with ${status}${responseBody ? `: ${responseBody}` : ''}`);
}
}
export interface ConnectionOptions {
serverUrl: string;
username: string;
appPassword: string;
apiPath?: string;
}
export interface CardUpdate {
title: string;
type: string;
owner: string;
description: string | null;
order: number;
duedate: string | null;
startdate: string | null;
archived: boolean;
done: string | null;
color: string;
}
function encodeBasicAuth(username: string, password: string): string {
const bytes = new TextEncoder().encode(`${username}:${password}`);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function normalizeServerUrl(value: string): URL {
const url = new URL(value.trim());
const isLocal = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
if (url.protocol !== 'https:' && !(isLocal && url.protocol === 'http:')) {
throw new Error('Decky requires HTTPS except when connecting to localhost.');
}
url.pathname = `${url.pathname.replace(/\/+$/, '')}/`;
url.search = '';
url.hash = '';
return url;
}
function ownerId(owner: string | DeckUser): string {
if (typeof owner === 'string') return owner;
return owner.uid || owner.primaryKey || '';
}
export function cardUpdateFrom(card: Card, changes: Partial<CardUpdate> = {}): CardUpdate {
return {
title: changes.title ?? card.title,
type: changes.type ?? card.type ?? 'plain',
owner: changes.owner ?? ownerId(card.owner),
description: changes.description !== undefined ? changes.description : card.description ?? null,
order: changes.order ?? card.order,
duedate: changes.duedate !== undefined ? changes.duedate : card.duedate ?? null,
startdate: changes.startdate !== undefined ? changes.startdate : card.startdate ?? null,
archived: changes.archived ?? card.archived ?? false,
done: changes.done !== undefined ? changes.done : card.done ?? null,
color: normalizeColor(changes.color ?? card.color)
};
}
export class DeckApiClient {
private constructor(
private readonly options: ConnectionOptions,
public readonly apiBaseUrl: URL,
private readonly fetchImpl: FetchLike
) {}
static async connect(options: ConnectionOptions, fetchImpl: FetchLike = tauriFetch): Promise<DeckApiClient> {
if (!options.username.trim() || !options.appPassword) throw new Error('Username and app password are required.');
const server = normalizeServerUrl(options.serverUrl);
const paths = [...new Set([options.apiPath, DOCUMENTED_API_PATH, REWRITTEN_API_PATH].filter(Boolean))] as string[];
let lastError: unknown;
for (const path of paths) {
const client = new DeckApiClient(options, new URL(path.replace(/^\/+/, ''), server), fetchImpl);
try {
await client.getBoards();
return client;
} catch (error) {
lastError = error;
if (!(error instanceof DeckApiError) || ![404, 405].includes(error.status)) throw error;
}
}
throw lastError ?? new Error('The Nextcloud Deck API could not be reached.');
}
getBoards(): Promise<Board[]> {
return this.request<Board[]>('boards');
}
getStacks(boardId: number): Promise<Stack[]> {
return this.request<Stack[]>(`boards/${boardId}/stacks`);
}
updateStack(boardId: number, stackId: number, title: string, order: number): Promise<Stack> {
return this.request<Stack>(`boards/${boardId}/stacks/${stackId}`, {
method: 'PUT',
body: JSON.stringify({ title, order })
});
}
async reorderStack(boardId: number, stackId: number, order: number): Promise<Stack[]> {
const server = normalizeServerUrl(this.options.serverUrl);
const url = new URL(`ocs/v2.php/apps/deck/api/v1.0/stacks/${stackId}/reorder`, server);
url.searchParams.set('format', 'json');
await this.requestUrl<unknown>(url, {
method: 'PUT',
body: JSON.stringify({ order, boardId })
});
const stacks = (await this.getStacks(boardId))
.filter((stack) => !stack.deletedAt)
.sort((a, b) => a.order - b.order);
const actualIndex = stacks.findIndex((stack) => stack.id === stackId);
if (actualIndex !== order) {
throw new Error(`Nextcloud did not persist the stack reorder (expected position ${order + 1}, received ${actualIndex + 1}).`);
}
return stacks;
}
getCard(boardId: number, stackId: number, cardId: number): Promise<Card> {
return this.request<Card>(`boards/${boardId}/stacks/${stackId}/cards/${cardId}`);
}
createCard(boardId: number, stackId: number, draft: CardDraft): Promise<Card> {
return this.request<Card>(`boards/${boardId}/stacks/${stackId}/cards`, {
method: 'POST',
body: JSON.stringify({
title: draft.title,
type: 'plain',
order: 999,
description: draft.description || null,
duedate: draft.dueDate ? new Date(draft.dueDate).toISOString() : null,
color: normalizeColor(draft.color)
})
});
}
updateCard(boardId: number, stackId: number, cardId: number, update: CardUpdate): Promise<Card> {
return this.request<Card>(`boards/${boardId}/stacks/${stackId}/cards/${cardId}`, {
method: 'PUT',
body: JSON.stringify(update)
});
}
deleteCard(boardId: number, stackId: number, cardId: number): Promise<void> {
return this.request<void>(`boards/${boardId}/stacks/${stackId}/cards/${cardId}`, { method: 'DELETE' });
}
async reorderCard(boardId: number, sourceStackId: number, cardId: number, stackId: number, order: number): Promise<Card> {
const body = JSON.stringify({ stackId, order });
await this.request<unknown>(`boards/${boardId}/stacks/${sourceStackId}/cards/${cardId}/reorder`, { method: 'PUT', body });
let card = await this.resolveMovedCard(boardId, sourceStackId, stackId, cardId);
if (sourceStackId !== stackId && card.stackId === sourceStackId) {
await this.request<unknown>(`boards/${boardId}/stacks/${stackId}/cards/${cardId}/reorder`, { method: 'PUT', body });
card = await this.resolveMovedCard(boardId, sourceStackId, stackId, cardId);
}
return card;
}
private async resolveMovedCard(boardId: number, sourceStackId: number, targetStackId: number, cardId: number): Promise<Card> {
try {
return await this.getCard(boardId, targetStackId, cardId);
} catch (error) {
if (error instanceof DeckApiError && error.status === 404 && sourceStackId !== targetStackId) {
return this.getCard(boardId, sourceStackId, cardId);
}
throw error;
}
}
private async request<T>(relativePath: string, init: RequestInit = {}): Promise<T> {
const url = new URL(relativePath, this.apiBaseUrl).toString();
return this.requestUrl<T>(url, init);
}
private async requestUrl<T>(url: string | URL, init: RequestInit = {}): Promise<T> {
const urlString = url.toString();
const response = await this.fetchImpl(urlString, {
...init,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'OCS-APIRequest': 'true',
Authorization: `Basic ${encodeBasicAuth(this.options.username, this.options.appPassword)}`,
...init.headers
}
});
if (!response.ok) throw new DeckApiError(response.status, urlString, await response.text());
if (response.status === 204) return undefined as T;
const text = await response.text();
return (text ? JSON.parse(text) : undefined) as T;
}
}

View file

@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { moveCard, moveStack } from './reorder';
import type { Stack } from './types';
const stacks: Stack[] = [
{ id: 1, boardId: 9, title: 'One', order: 0, cards: [
{ id: 10, stackId: 1, title: 'A', type: 'plain', owner: 'admin', order: 0 },
{ id: 11, stackId: 1, title: 'B', type: 'plain', owner: 'admin', order: 1 }
] },
{ id: 2, boardId: 9, title: 'Two', order: 1, cards: [
{ id: 20, stackId: 2, title: 'C', type: 'plain', owner: 'admin', order: 0 }
] },
{ id: 3, boardId: 9, title: 'Three', order: 2, cards: [] }
];
describe('reorder helpers', () => {
it('moves a card between stacks and assigns contiguous order values', () => {
const result = moveCard(stacks, 10, 1, 2, 1)!;
expect(result.targetIndex).toBe(1);
expect(result.stacks[0].cards.map((card) => [card.title, card.order])).toEqual([['B', 0]]);
expect(result.stacks[1].cards.map((card) => [card.title, card.stackId, card.order])).toEqual([
['C', 2, 0], ['A', 2, 1]
]);
});
it('accounts for the removed item when reordering inside one stack', () => {
const result = moveCard(stacks, 10, 1, 1, 2)!;
expect(result.targetIndex).toBe(1);
expect(result.stacks[0].cards.map((card) => card.title)).toEqual(['B', 'A']);
});
it('moves stacks before or after the selected target', () => {
const result = moveStack(stacks, 1, 2, true)!;
expect(result.targetIndex).toBe(1);
expect(result.stacks.map((stack) => [stack.title, stack.order])).toEqual([
['Two', 0], ['One', 1], ['Three', 2]
]);
});
it('supports consecutive card previews while a drag crosses multiple positions', () => {
const firstPreview = moveCard(stacks, 10, 1, 2, 1)!;
const secondPreview = moveCard(firstPreview.stacks, 10, 2, 2, 0)!;
expect(secondPreview.stacks[1].cards.map((card) => [card.title, card.order])).toEqual([
['A', 0], ['C', 1]
]);
});
it('supports consecutive stack previews while a drag crosses multiple stacks', () => {
const firstPreview = moveStack(stacks, 1, 2, true)!;
const secondPreview = moveStack(firstPreview.stacks, 1, 3, true)!;
expect(secondPreview.stacks.map((stack) => [stack.title, stack.order])).toEqual([
['Two', 0], ['Three', 1], ['One', 2]
]);
});
});

44
app/src/lib/reorder.ts Normal file
View file

@ -0,0 +1,44 @@
import type { Stack } from './types';
export function moveCard(
stacks: Stack[],
cardId: number,
sourceStackId: number,
targetStackId: number,
requestedIndex: number
): { stacks: Stack[]; targetIndex: number } | null {
const source = stacks.find((stack) => stack.id === sourceStackId);
const sourceIndex = source?.cards.findIndex((card) => card.id === cardId) ?? -1;
const card = source?.cards[sourceIndex];
if (!source || !card || sourceIndex < 0) return null;
const next = stacks.map((stack) => ({ ...stack, cards: [...(stack.cards ?? [])] }));
const nextSource = next.find((stack) => stack.id === sourceStackId)!;
nextSource.cards.splice(sourceIndex, 1);
const nextTarget = next.find((stack) => stack.id === targetStackId);
if (!nextTarget) return null;
let targetIndex = requestedIndex;
if (sourceStackId === targetStackId && sourceIndex < targetIndex) targetIndex -= 1;
targetIndex = Math.max(0, Math.min(targetIndex, nextTarget.cards.length));
nextTarget.cards.splice(targetIndex, 0, { ...card, stackId: targetStackId });
next.forEach((stack) => stack.cards.forEach((item, order) => { item.order = order; }));
return { stacks: next, targetIndex };
}
export function moveStack(
stacks: Stack[],
stackId: number,
targetStackId: number,
after: boolean
): { stacks: Stack[]; targetIndex: number } | null {
const moved = stacks.find((stack) => stack.id === stackId);
if (!moved || !stacks.some((stack) => stack.id === targetStackId) || stackId === targetStackId) return null;
const next = stacks.filter((stack) => stack.id !== stackId).map((stack) => ({ ...stack }));
let targetIndex = next.findIndex((stack) => stack.id === targetStackId);
if (after) targetIndex += 1;
targetIndex = Math.max(0, targetIndex);
next.splice(targetIndex, 0, { ...moved });
next.forEach((stack, order) => { stack.order = order; });
return { stacks: next, targetIndex };
}

31
app/src/lib/settings.ts Normal file
View file

@ -0,0 +1,31 @@
import type { AppSettings } from './types';
const key = 'decky.settings.v2';
export const defaultSettings: AppSettings = {
serverUrl: '',
username: '',
sidebarCollapsed: false,
boardViews: {},
activeColor: 'E69A88',
completedColor: '80C3A5'
};
export function loadSettings(): AppSettings {
try {
const value = localStorage.getItem(key);
if (!value) return structuredClone(defaultSettings);
const parsed = JSON.parse(value) as Partial<AppSettings>;
return {
...structuredClone(defaultSettings),
...parsed,
boardViews: parsed.boardViews ?? {}
};
} catch {
return structuredClone(defaultSettings);
}
}
export function saveSettings(settings: AppSettings): void {
localStorage.setItem(key, JSON.stringify(settings));
}

63
app/src/lib/types.ts Normal file
View file

@ -0,0 +1,63 @@
export type BoardOrientation = 'horizontal' | 'vertical';
export interface DeckUser {
primaryKey?: string;
uid?: string;
displayname?: string;
}
export interface Board {
id: number;
title: string;
owner?: DeckUser;
color?: string | null;
archived?: boolean;
lastModified?: number;
deletedAt?: number;
}
export interface Stack {
id: number;
boardId: number;
title: string;
order: number;
deletedAt?: number;
cards: Card[];
}
export interface Card {
id: number;
stackId: number;
title: string;
description?: string | null;
type: string;
owner: string | DeckUser;
order: number;
archived?: boolean;
done?: string | null;
duedate?: string | null;
startdate?: string | null;
color?: string | null;
lastModified?: number;
}
export interface CardDraft {
title: string;
description: string;
dueDate: string;
color: string;
}
export interface BoardViewSettings {
orientation: BoardOrientation;
}
export interface AppSettings {
serverUrl: string;
username: string;
selectedBoardId?: number;
sidebarCollapsed: boolean;
boardViews: Record<number, BoardViewSettings>;
activeColor: string;
completedColor: string;
}