feat(data): hele kontoens opgaver kan hentes, og en indsat liste bliver til opgaver

To ønsker, meldt sammen.

--- eksport af det hele ---

Eksporten fandtes allerede — konto som JSON, noter som Markdown i en zip,
og pr. projekt CSV i Todoist-format og ICS. Hullet var, at opgaver kun
kunne hentes ÉT PROJEKT AD GANGEN. Med femten projekter er det femten
downloads for at flytte sine egne opgaver, hvilket er en eksport, der
teknisk set findes, og som ingen bruger.

`GET /export/projects.zip` er én CSV pr. projekt. En zip og ikke én bred
fil med en projektkolonne: Todoists egen eksport er en fil pr. projekt, så
det er den form, der importeres i den anden ende — og den form, man kan
hive ét projekt ud af uden at åbne et regneark. Samme begrundelse som
noteeksporten, der er en mappe med Markdown og ikke én lang fil.

`GET /export/tasks.ics` er alle daterede opgaver i én kalender. Ét fil og
ikke en zip, fordi en kalender er lavet til at rumme mange ting, og
projektet følger med hver opgave. Udaterede udelades — en VTODO uden dato
er en linje, de fleste læsere taber på gulvet.

Prøven ser I filerne frem for på statuskoden: en tom zip og en kalender
uden VTODO'er er begge 200 med en troværdig Content-Type, og det er
præcis den form, en fejl i en eksport har. To projekter, ikke ét, så en
løkke der kun eksporterer det sidste ikke består.

--- en indsat liste bliver til opgaver ---

Quick-add er ét felt på én linje, og browseren smider linjeskiftene væk
ved indsætning i et `<input>` — så en kopieret liste blev til én opgave
med punkterne mast sammen.

Nu læses den. Indrykning bærer niveauet, og bredden læses af teksten frem
for at være gættet til to eller fire: en liste fra Apple Noter rykker ind
med ét antal mellemrum, en fra et Markdown-dokument med et andet, og
begge skal give ét niveau. `- `, `*`, `1.` og `1)` fjernes som den
notation, de er. `- [x]` er derimod indhold: en halvt afkrydset liste,
der kommer ind som lutter uløste opgaver, har tabt det, man indsatte den
for.

Teksten røres ikke ud over mærket. Datoer, `#projekt` og `@etiket` læses
af quickadd på serveren, præcis som når linjen var skrevet i hånden — ét
sted at forstå en opgavelinje, ikke to. Derfor tager `/tasks/quick-add`
nu et `parent_id`: en indrykket linje skal parses som enhver anden.

En undertask lever i sin forælders projekt. Et `#projekt` på en indrykket
linje honoreres derfor ikke, og det siges højt som `unknown_project` frem
for at ordet bare forsvinder fra titlen.

Indsætter man i en opgaves undertask-felt, bliver linjerne undertasks
dér. Det er "enten opgaver eller undertasks" afgjort af hvor man
indsætter, uden at nogen skal vælge noget i en menu.

Listen vises, før den oprettes. Fyrre opgaver fra et fejlklik er ikke
noget, man skal opdage bagefter — og visningen er også svaret på "forstod
den indrykningen", stillet mens der stadig er en Fortryd-knap.

--- rettet undervejs ---

Rækkens undertask-tæller stod stille, når en undertask blev tilføjet fra
ruden: rækken sagde 1/2, mens ruden foran viste fire. Tallene bor på
forælderen og regnes ud ved hentning, og serveren sender ikke forælderen
igen, fordi et barn blev lavet. Det gjaldt også den gamle vej, ikke kun
den nye.

Go-suiten grøn. Fuld chromium-suite: 103 bestået, 0 fejlet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-10-06 12:27:46 +02:00
co-authored by Claude Opus 5
parent 44b28f1cd9
commit 3132bba7e5
15 changed files with 918 additions and 6 deletions
+83
View File
@@ -0,0 +1,83 @@
import { test, expect } from '@playwright/test';
import { readList } from '../src/lib/pastelist.js';
/**
* Læsningen af en indsat liste, prøvet for sig.
*
* Reglerne er små og mange, og de er billigere at stille her end gennem en
* browser: hvad er et mærke, hvad er indhold, og hvor dybt står en linje. Flowet
* gennem fladen har sin egen prøve i smoke.spec.js — den viser, at det hænger
* sammen, ikke at hvert tilfælde er rigtigt.
*/
test.describe('en indsat liste læses som opgaver og underopgaver', () => {
test('mærker fjernes, teksten bliver', () => {
expect(readList('- køb mælk\n* ring til Per\n+ hent pakken\n')).toEqual([
{ depth: 0, text: 'køb mælk', done: false },
{ depth: 0, text: 'ring til Per', done: false },
{ depth: 0, text: 'hent pakken', done: false }
]);
});
test('nummererede lister er også lister', () => {
expect(readList('1. først\n2) så\n10. til sidst')).toEqual([
{ depth: 0, text: 'først', done: false },
{ depth: 0, text: 'så', done: false },
{ depth: 0, text: 'til sidst', done: false }
]);
});
test('en afkrydsning er indhold, ikke notation', () => {
// Det er hele grunden til at læse boksen: en halvt afkrydset liste, der
// kommer ind som lutter uløste opgaver, har tabt det, man indsatte den for.
expect(readList('- [x] betalt\n- [ ] ikke betalt\n- [X] også betalt')).toEqual([
{ depth: 0, text: 'betalt', done: true },
{ depth: 0, text: 'ikke betalt', done: false },
{ depth: 0, text: 'også betalt', done: true }
]);
});
test('indrykning bærer niveauet, uanset hvor bred den er', () => {
// To mellemrum i den ene liste, fire i den anden, en tabulator i den tredje:
// alle tre betyder ét niveau. Bredden gættes ikke, den læses af teksten.
const to = readList('a\n b\nc');
const fire = readList('a\n b\nc');
const tab = readList('a\n\tb\nc');
const want = [
{ depth: 0, text: 'a', done: false },
{ depth: 1, text: 'b', done: false },
{ depth: 0, text: 'c', done: false }
];
expect(to).toEqual(want);
expect(fire).toEqual(want);
expect(tab).toEqual(want);
});
test('en liste, der er rykket ind som helhed, begynder stadig på niveau nul', () => {
// Kopierer man fra et dokument, følger der ofte indrykning med på alle
// linjer. Den er ikke et niveau — der er ikke noget at være under.
expect(readList(' a\n b')).toEqual([
{ depth: 0, text: 'a', done: false },
{ depth: 1, text: 'b', done: false }
]);
});
test('tomme linjer er luft i det kopierede, ikke punkter', () => {
// Og de nulstiller ikke niveauet: en liste med luft mellem punkterne er én
// liste.
expect(readList('a\n\n b\n\n c')).toEqual([
{ depth: 0, text: 'a', done: false },
{ depth: 1, text: 'b', done: false },
{ depth: 1, text: 'c', done: false }
]);
});
test('teksten røres ikke ud over mærket', () => {
// Datoer, #projekt og @etiket læses af quickadd på serveren, præcis som når
// linjen er skrevet i hånden. To steder at forstå en opgavelinje er ét for
// mange.
expect(readList('- betal moms i morgen #Firma p1\n- andet')).toEqual([
{ depth: 0, text: 'betal moms i morgen #Firma p1', done: false },
{ depth: 0, text: 'andet', done: false }
]);
});
});
+126
View File
@@ -5094,6 +5094,132 @@ test('flere markerede afsnit bliver til én monotypeblok', async ({ page }) => {
expect(trouble).toEqual([]);
});
/**
* Hele kontoens opgaver kan hentes i én omgang.
*
* Eksporten pr. projekt virkede og var ubrugelig ved enhver størrelse: femten
* projekter betød femten downloads for at flytte sine egne opgaver et andet sted
* hen. Det er en eksport, der teknisk set findes, og som ingen bruger.
*
* Prøven henter filerne og ser i dem. At et link findes beviser ingenting — en tom
* zip og en kalender uden VTODO'er er begge 200 med en troværdig Content-Type, og
* det er præcis den form, en fejl i en eksport har.
*/
test('alle opgaver kan hentes som CSV og som kalender', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
await page.getByLabel('Ny opgave').fill('Betal moms i dag');
await page.getByLabel('Ny opgave').press('Enter');
await expect(page.getByText('Betal moms', { exact: true })).toBeVisible();
await page.goto('/indstillinger/data');
const hent = async (navn) => {
const venter = page.waitForEvent('download');
await page.getByRole('link', { name: navn }).click();
const fil = await venter;
const sti = await fil.path();
return { sti, navn: fil.suggestedFilename() };
};
const zip = await hent('Alle opgaver (CSV)');
expect(zip.navn).toMatch(/\.zip$/);
// En zip er ikke tom, når den har mere end den afsluttende blok i sig.
const { size } = await import('node:fs/promises').then((fs) => fs.stat(zip.sti));
expect(size).toBeGreaterThan(100);
const kal = await hent('Alle opgaver (kalender)');
expect(kal.navn).toMatch(/\.ics$/);
const tekst = await import('node:fs/promises').then((fs) => fs.readFile(kal.sti, 'utf8'));
expect(tekst).toContain('BEGIN:VTODO');
expect(tekst).toContain('Betal moms');
expect(trouble).toEqual([]);
});
/**
* En indsat liste bliver til opgaver og underopgaver.
*
* Quick-add er ét felt på én linje, og en browser smider linjeskiftene væk, når
* man indsætter i et `<input>` — så en kopieret liste blev til én opgave med det
* hele mast sammen.
*
* Her måles sammenhængen: at indsætningen fanges, at visningen kommer FØR noget
* oprettes, at indrykningen bliver til en underopgave, og at en afkrydset linje
* lander som gjort. De enkelte læseregler står i pastelist.spec.js, hvor de er
* billigere at stille.
*/
test('en indsat liste bliver til opgaver, og de indrykkede bliver underopgaver', async ({
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const liste = ['- Flytte garagen', ' - Rydde hylderne', ' - [x] Leje trailer', '- Male døren'].join(
'\n'
);
await page.getByLabel('Ny opgave').click();
// En rigtig indsættelses-hændelse. At skrive teksten ind i feltet ville springe
// netop det over, prøven handler om — browseren smider linjeskiftene væk.
await page.evaluate((tekst) => {
const dt = new DataTransfer();
dt.setData('text/plain', tekst);
document
.querySelector('[data-quickadd]')
.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
}, liste);
// Vist først, oprettet bagefter. Fyrre opgaver fra et fejlklik er ikke noget,
// man skal opdage bagefter.
const visning = page.getByRole('group', { name: 'Indsat liste' });
await expect(visning.locator('li')).toHaveCount(4);
await expect(page.getByText('Flytte garagen', { exact: true })).toHaveCount(1);
await visning.getByRole('button', { name: 'Opret 4 opgaver' }).click();
await expect(visning).toHaveCount(0);
// Ingen af linjerne har en dato, så de ligger i indbakken og ikke på I dag.
await page.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: 'Indbakke' }).click();
await expect(page.getByText('Flytte garagen', { exact: true })).toBeVisible();
await expect(page.getByText('Male døren', { exact: true })).toBeVisible();
// Og de indrykkede hænger under den linje, de stod under — ikke under den
// første, den bedste. Det ses i opgavens egen rude.
await page.getByText('Flytte garagen', { exact: true }).click();
await expect(page.getByText('Rydde hylderne', { exact: true })).toBeVisible();
await expect(page.getByText('Leje trailer', { exact: true })).toBeVisible();
// Afkrydsningen var indhold: den linje kom ind som gjort, så rækken tæller
// én af to. Målt på tælleren frem for på en streg gennem teksten — tælleren
// er det, serveren har gemt, stregen er, hvordan den tegnes.
await expect(page.getByRole('button', { name: /Flytte garagen 1\/2/ })).toBeVisible();
// Og "Male døren" hang ikke under noget: den stod uden indrykning.
await expect(page.getByRole('button', { name: /Male døren \d\/\d/ })).toHaveCount(0);
// Den anden halvdel af "enten opgaver eller undertasks": hvor man indsætter,
// afgør hvad linjerne bliver. Her er opgavens egen rude åben, så de bliver
// undertasks under den — uden at nogen skal vælge noget.
await page.evaluate((tekst) => {
const dt = new DataTransfer();
dt.setData('text/plain', tekst);
document
.querySelector('input[aria-label="Ny undertask"]')
.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
}, '- Købe maling\n- Låne stige');
const flere = page.getByRole('group', { name: 'Indsat liste' });
await expect(flere.locator('li')).toHaveCount(2);
await flere.getByRole('button', { name: 'Opret 2 opgaver' }).click();
// To mere under den samme opgave: fire undertasks, én af dem lukket.
await expect(page.getByRole('button', { name: /Flytte garagen 1\/4/ })).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* En kodeblok med flere linjer kommer tilbage med dem alle.
*
+9 -2
View File
@@ -365,8 +365,13 @@ export const api = {
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
snoozeTask: (id, until) => post(`/tasks/${id}/snooze`, { until: until ?? '' }),
quickAdd: (text, projectId, sectionId) =>
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
quickAdd: (text, projectId, sectionId, parentId) =>
post('/tasks/quick-add', {
text,
project_id: projectId,
section_id: sectionId,
parent_id: parentId
}),
quickAddPreview: (text, signal) =>
get(`/tasks/quick-add/preview?text=${encodeURIComponent(text)}`, { signal }),
@@ -511,6 +516,8 @@ export const api = {
importCSV: (data) => post('/import/csv', data),
exportAccountURL: () => '/api/v1/export/account',
exportNotesURL: () => '/api/v1/export/notes.zip',
exportAllCSVURL: () => '/api/v1/export/projects.zip',
exportAllICSURL: () => '/api/v1/export/tasks.ics',
// Through `upload`, not `request`: a multipart body has to set its own boundary,
// and the browser only does that when nothing has claimed the header.
importNotes: (file) => upload('/notes/import', file),
+126 -1
View File
@@ -15,6 +15,7 @@
import { api } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
import { readList } from '$lib/pastelist.js';
let { projectId = undefined, onadded = undefined, autofocus = false } = $props();
let text = $state('');
@@ -97,6 +98,60 @@
return out;
});
/**
* En indsat liste bliver til opgaver frem for til én opgave med det hele i.
*
* Browseren smider linjeskiftene væk, når man indsætter i et `<input>`, så uden
* det her blev en kopieret liste til én opgave med punkterne mast sammen. En
* enkelt linje røres ikke — det er almindelig indsætning, og den skal opføre sig
* som altid.
*
* Vist før den oprettes. Fyrre opgaver fra et fejlklik er ikke noget, man skal
* opdage bagefter, og listen er også svaret på "forstod den indrykningen" —
* stillet mens der stadig er en Fortryd-knap.
*/
let pending = $state([]);
function onpaste(event) {
const pasted = event.clipboardData?.getData('text/plain') ?? '';
if (!pasted.includes('\n')) return;
const rows = readList(pasted);
if (rows.length < 2) return;
event.preventDefault();
pending = rows;
}
async function createPending() {
if (submitting) return;
submitting = true;
// Den seneste opgave på hvert niveau, så en indrykket linje havner under den
// linje, den stod under — og ikke under den første, den bedste.
const parents = [];
let made = 0;
for (const row of pending) {
const parentId = row.depth > 0 ? parents[row.depth - 1] : undefined;
const task = await app.quickAdd(row.text, projectId, undefined, parentId);
// Stopper ved den første, der ikke kunne laves. At køre videre ville lægge
// resten af listen ind uden den, de hang under, og det er værre end at
// standse et sted, man kan se.
if (!task) break;
parents[row.depth] = task.id;
parents.length = row.depth + 1;
if (row.done) await app.complete(task.id);
onadded?.(task);
made++;
}
submitting = false;
// Det, der ikke nåede at blive lavet, bliver stående, så man kan se hvor langt
// den kom og prøve resten igen.
pending = pending.slice(made);
input?.focus();
}
async function submit(event) {
event?.preventDefault();
const value = text.trim();
@@ -164,13 +219,32 @@
aria-label={t('task.new')}
autocomplete="off"
spellcheck="false"
{onpaste}
/>
</div>
{#if text.trim()}
{#if text.trim() && !pending.length}
<button type="submit" class="submit" disabled={submitting}>{t('task.add')}</button>
{/if}
{#if pending.length}
<div class="pasted" role="group" aria-label={t('task.pastedTitle')}>
<ul>
{#each pending as row, i (i)}
<li style="--depth: {row.depth}" class:done={row.done}>{row.text}</li>
{/each}
</ul>
<div class="actions">
<button type="button" class="submit" disabled={submitting} onclick={createPending}>
{t('task.pastedAdd', { n: String(pending.length) })}
</button>
<button type="button" class="cancel" onclick={() => (pending = [])}>
{t('task.pastedCancel')}
</button>
</div>
</div>
{/if}
<!-- What the parser understands, spelled out while somebody is typing into it.
It was in the placeholder before, which is the one place a hint cannot be:
the placeholder disappears at the first keystroke, so the help was gone
@@ -190,6 +264,57 @@
</form>
<style>
/* Den indsatte liste, før den bliver til opgaver. Ligger i flowet og ikke
svævende: den skal kunne klikkes på, og den er noget, man læser færdig frem
for et vink, der forsvinder. */
.pasted {
grid-column: 1 / -1;
margin-top: var(--s2);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
padding: var(--s2);
}
.pasted ul {
list-style: none;
margin: 0 0 var(--s2);
padding: 0;
max-height: 40vh;
overflow-y: auto;
}
.pasted li {
/* Indrykningen er hele pointen med visningen: den viser, hvad der bliver en
underopgave, mens der stadig er en vej tilbage. */
padding: 2px 0 2px calc(var(--depth) * var(--s4));
font-size: var(--text-sm);
color: var(--ink);
}
.pasted li.done {
text-decoration: line-through;
color: var(--ink-muted);
}
.pasted .actions {
display: flex;
gap: var(--s2);
align-items: center;
}
.cancel {
border: none;
background: none;
padding: 0;
font: inherit;
color: var(--ink-muted);
cursor: pointer;
}
.cancel:hover {
text-decoration: underline;
}
/* Sits under the field it explains, in the same rhythm as a hint anywhere else. */
.syntax {
/* Nothing to click, and it floats over the first task in the list — which it
+130 -1
View File
@@ -15,6 +15,7 @@
import { focusOnMount } from '$lib/focus.js';
import { t, tag } from '$lib/i18n.svelte.js';
import { REPEATS } from '$lib/when.js';
import { readList } from '$lib/pastelist.js';
let { task, onclose } = $props();
@@ -282,6 +283,73 @@
// --- sub-tasks ----------------------------------------------------------------
/**
* En liste indsat i undertask-feltet bliver til undertasks under DEN her opgave.
*
* Det er den anden halvdel af "enten opgaver eller undertasks": hvor man
* indsætter, afgør hvad linjerne bliver, uden at nogen skal vælge noget i en
* menu. Indrykning i det indsatte giver stadig et niveau mere, så en liste med
* punkter under punkter kommer ind som den stod.
*
* Som i quick-add vises den, før den oprettes — og af samme grund.
*/
let pastedSubtasks = $state([]);
function onPasteSubtasks(event) {
const pasted = event.clipboardData?.getData('text/plain') ?? '';
if (!pasted.includes('\n')) return;
const rows = readList(pasted);
if (rows.length < 2) return;
event.preventDefault();
pastedSubtasks = rows;
}
async function createPastedSubtasks() {
if (addingPasted) return;
addingPasted = true;
// Niveau nul hænger under den åbne opgave; dybere niveauer hænger under den
// seneste linje ovenfor, som i quick-add.
const parents = [task.id];
let made = 0;
for (const row of pastedSubtasks) {
const created = await app.quickAdd(row.text, task.project_id, undefined, parents[row.depth]);
if (!created) break;
parents[row.depth + 1] = created.id;
parents.length = row.depth + 2;
if (row.done) await app.complete(created.id);
// Kun dem, der hører direkte under den åbne opgave, står i listen her —
// resten hører under deres egen.
if (row.depth === 0) subtasks = [...subtasks, created];
made++;
}
addingPasted = false;
pastedSubtasks = pastedSubtasks.slice(made);
countSubtasks();
}
let addingPasted = $state(false);
/**
* Rækkens tæller, når en undertask er kommet til eller er blevet lukket.
*
* Tallene bor på FORÆLDEREN og sendes med hver opgave, så listen kan sige
* "1/4" uden at åbne noget. Serveren regner dem ud, når opgaverne hentes — den
* sender ikke forælderen igen, fordi et barn blev lavet. Så uden det her stod
* rækken bagved og sagde 1/2, mens ruden foran viste fire undertasks.
*
* Samme regel som resten af butikken: en lokal handling slår igennem med det
* samme og bliver afstemt bagefter. Næste hentning henter serverens tal.
*/
function countSubtasks() {
app.upsert({
...task,
subtask_count: subtasks.length,
subtask_done: subtasks.filter((s) => s.completed).length
});
}
async function addSubtask(event) {
event.preventDefault();
const text = newSubtask.trim();
@@ -294,6 +362,7 @@
parent_id: task.id
});
subtasks = [...subtasks, created];
countSubtasks();
} catch (e) {
app.toast(humanMessage(e));
}
@@ -635,8 +704,31 @@
{/each}
<form onsubmit={addSubtask}>
<input bind:value={newSubtask} placeholder={t('detail.addSubtask')} aria-label={t('detail.newSubtask')} />
<input
bind:value={newSubtask}
onpaste={onPasteSubtasks}
placeholder={t('detail.addSubtask')}
aria-label={t('detail.newSubtask')}
/>
</form>
{#if pastedSubtasks.length}
<div class="pasted" role="group" aria-label={t('task.pastedTitle')}>
<ul>
{#each pastedSubtasks as row, i (i)}
<li style="--depth: {row.depth}" class:done={row.done}>{row.text}</li>
{/each}
</ul>
<div class="pasted-actions">
<button type="button" disabled={addingPasted} onclick={createPastedSubtasks}>
{t('task.pastedAdd', { n: String(pastedSubtasks.length) })}
</button>
<button type="button" class="plain" onclick={() => (pastedSubtasks = [])}>
{t('task.pastedCancel')}
</button>
</div>
</div>
{/if}
</section>
<section>
@@ -741,6 +833,43 @@
</aside>
<style>
/* Den indsatte liste, før den bliver til undertasks. Samme form som i
quick-add — det er den samme beslutning, stillet samme sted i forløbet. */
.pasted {
margin-top: var(--s2);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: var(--s2);
}
.pasted ul {
list-style: none;
margin: 0 0 var(--s2);
padding: 0;
max-height: 30vh;
overflow-y: auto;
}
.pasted li {
padding: 2px 0 2px calc(var(--depth) * var(--s4));
font-size: var(--text-sm);
}
.pasted li.done {
text-decoration: line-through;
color: var(--ink-muted);
}
.pasted-actions {
display: flex;
gap: var(--s2);
align-items: center;
}
.pasted-actions .plain {
border: none;
background: none;
padding: 0;
font: inherit;
color: var(--ink-muted);
cursor: pointer;
}
.linked-note {
display: block;
padding: var(--s2);
+5
View File
@@ -263,6 +263,9 @@ export const da = {
// --- a task in a list ----------------------------------------------------------
'task.add': 'Tilføj',
'task.pastedTitle': 'Indsat liste',
'task.pastedAdd': 'Opret {n} opgaver',
'task.pastedCancel': 'Fortryd',
'task.new': 'Ny opgave',
'task.syntaxProject': 'projekt',
'task.syntaxSection': 'sektion',
@@ -611,6 +614,8 @@ export const da = {
'data.importNotesHint': 'Én .md-fil pr. note; første linje bliver titlen. Billeder, der ligger ved siden af, følger med og bliver hængt på noten. Scriptet i tools/ henter dine Apple Noter ud.',
'data.notesImported': '{n} noter og {files} filer hentet ind.',
'data.exportNotes': 'Alle noter som Markdown-filer',
'data.exportAllCSV': 'Alle opgaver (CSV)',
'data.exportAllICS': 'Alle opgaver (kalender)',
'data.exportAccount': 'Hele kontoen som JSON',
'data.exportOne': 'Ét projekt',
'data.pickProject': 'Vælg et projekt',
+5
View File
@@ -260,6 +260,9 @@ export const en = {
// --- a task in a list ----------------------------------------------------------
'task.add': 'Add',
'task.pastedTitle': 'Pasted list',
'task.pastedAdd': 'Add {n} tasks',
'task.pastedCancel': 'Cancel',
'task.new': 'New task',
'task.syntaxProject': 'project',
'task.syntaxSection': 'section',
@@ -607,6 +610,8 @@ export const en = {
'data.importNotesHint': 'One .md file per note; the first line becomes the title. Pictures beside the files come with them and are attached to the note. The script in tools/ gets your Apple Notes out.',
'data.notesImported': '{n} notes and {files} files brought in.',
'data.exportNotes': 'Every note as Markdown files',
'data.exportAllCSV': 'All tasks (CSV)',
'data.exportAllICS': 'All tasks (calendar)',
'data.exportAccount': 'The whole account as JSON',
'data.exportOne': 'One project',
'data.pickProject': 'Pick a project',
+64
View File
@@ -0,0 +1,64 @@
/**
* En indsat liste, læst som opgaver og underopgaver.
*
* Quick-add er ét felt på én linje, og en browser smider linjeskiftene væk, når
* man indsætter i et `<input>` — så en kopieret liste blev til én opgave med det
* hele mast sammen. Det her er den anden halvdel: hvad linjerne betyder.
*
* Indrykning bærer niveauet, samme regel som punktlisterne i noterne. Bredden
* læses af teksten selv frem for at være gættet til to eller fire: en liste fra
* Apple Noter rykker ind med ét antal mellemrum, en fra et Markdown-dokument med
* et andet, og begge dele skal give ét niveau. En stak over de indrykninger, der
* faktisk er set, klarer begge uden at kende tallet på forhånd.
*
* Mærkerne — `-`, `*`, `+`, `1.`, `1)` — fjernes, fordi de er listens notation og
* ikke en del af opgaven. En afkrydsningsboks er derimod indhold: `- [x]` siger,
* at punktet var gjort, og en halvt afkrydset liste, der kommer ind som lutter
* uløste opgaver, har tabt det, man indsatte den for.
*
* Teksten selv røres ikke ud over det. Datoer, `#projekt` og `@etiket` læses af
* quickadd på serveren, præcis som når linjen var skrevet i hånden — ét sted at
* forstå en opgavelinje, ikke to.
*/
/** `- `, `* `, `+ `, `1. `, `1) ` — listens egen notation, ikke opgavens navn. */
const MARKER = /^(?:[-*+]|\d+[.)])\s+/;
/** `[ ]`, `[x]`, `[X]` — står efter mærket, når der er et. */
const BOX = /^\[([ xX])\]\s*/;
/** Hvor bredt et stykke indrykning er, med en tabulator som to mellemrum. */
function width(indent) {
return indent.replace(/\t/g, ' ').length;
}
export function readList(text) {
const out = [];
// De indrykningsbredder, der står åbne lige nu — én pr. niveau.
const levels = [];
for (const raw of (text ?? '').split(/\r?\n/)) {
const indent = /^[ \t]*/.exec(raw)[0];
let rest = raw.slice(indent.length).trim();
// En tom linje er et mellemrum i den, man kopierede, ikke et punkt. Den
// nulstiller ikke niveauet: en liste med luft mellem punkterne er én liste.
if (!rest) continue;
rest = rest.replace(MARKER, '');
let done = false;
const box = BOX.exec(rest);
if (box) {
done = box[1] !== ' ';
rest = rest.slice(box[0].length);
}
rest = rest.trim();
if (!rest) continue;
const w = width(indent);
while (levels.length && w < levels[levels.length - 1]) levels.pop();
if (!levels.length || w > levels[levels.length - 1]) levels.push(w);
out.push({ depth: levels.length - 1, text: rest, done });
}
return out;
}
+2 -2
View File
@@ -524,9 +524,9 @@ class AppState {
* request is fast and the box clears immediately, which is where the
* responsiveness actually needs to be.
*/
async quickAdd(text, projectId) {
async quickAdd(text, projectId, sectionId, parentId) {
try {
const task = await api.quickAdd(text, projectId);
const task = await api.quickAdd(text, projectId, sectionId, parentId);
this.upsert(task);
return task;
} catch (e) {
@@ -393,6 +393,8 @@
do with a Content-Disposition, and the session cookie rides along. -->
<a class="link" href={api.exportAccountURL()} download>{t('data.exportAccount')}</a>
<a class="link" href={api.exportNotesURL()} download>{t('data.exportNotes')}</a>
<a class="link" href={api.exportAllCSVURL()} download>{t('data.exportAllCSV')}</a>
<a class="link" href={api.exportAllICSURL()} download>{t('data.exportAllICS')}</a>
</div>
<!-- The way back in, and from anywhere else: a folder of Markdown is what
@@ -410,6 +412,8 @@
<p class="hint">{t('data.importNotesHint')}</p>
</div>
<!-- Vælgeren bliver stående ved siden af "alle": den er stadig vejen til ét
projekt, og det er en anden opgave end at flytte hele kontoen. -->
<div class="field">
<label for="export-project">{t('data.exportOne')}</label>
<select id="export-project" bind:value={exportProject}>