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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ installed during migration.
- Ordered view navigation with Today, Upcoming, saved lists, boards, and calendars
- Grouped view sources and grouped list results
- Capture, editing, completion, and client-side search
- First-class image attachments with optional inline Notes embeds
- Projects, contexts, tags, recurrence, absolute reminders, and priorities
- Authority-backed, content-free reminders for connected mdbase collections
- Offline cloud replica with background synchronization and explicit conflict resolution
Expand Down Expand Up @@ -49,6 +50,21 @@ offline replica because it may temporarily hold writes that have not reached
the hosted authority. Mutations are serialized at the repository boundary,
while UI saves can continue after navigation.

An attachment has three deliberately separate sources of truth. A task's
frontmatter `attachments` link list owns membership; an optional image embed in
the Markdown body owns presentation; and the filesystem or mdbase file
descriptor owns binary metadata such as size, digest, media type, and revision.
Attaching therefore never rewrites Notes, detaching never deletes bytes, and a
detached file is retained by every provider. TaskNotes will expose permanent
deletion only when the collection authority can atomically prove that no task
membership or inline embed still refers to the bytes and delete them in the
same transaction.

```yaml
attachments:
- "[[Attachments/receipt.jpg]]"
```

Android retains access to selected folders through a persisted Storage Access
Framework grant. iOS retains a security-scoped bookmark and coordinates access
with the selected Files provider. Each folder has a separate disposable index,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.util.Base64;

import androidx.activity.result.ActivityResult;
import androidx.documentfile.provider.DocumentFile;
Expand Down Expand Up @@ -261,6 +262,83 @@ public void writeText(PluginCall call) {
});
}

@PluginMethod
public void readBinary(PluginCall call) {
run(call, () -> {
String id = requiredString(call, "selectionId");
String path = safePath(requiredString(call, "path"), false);
DocumentFile file = requireFile(id, path);
try (
InputStream raw = getContext().getContentResolver().openInputStream(file.getUri());
BufferedInputStream input = raw == null ? null : new BufferedInputStream(raw)
) {
if (input == null) {
throw new IOException("Could not open " + path + ".");
}
ByteArrayOutputStream output = new ByteArrayOutputStream();
copy(input, output);
JSObject response = new JSObject();
response.put("data", Base64.encodeToString(output.toByteArray(), Base64.NO_WRAP));
return response;
}
});
}

@PluginMethod
public void writeBinary(PluginCall call) {
run(call, () -> {
String id = requiredString(call, "selectionId");
String path = safePath(requiredString(call, "path"), false);
String data = call.getString("data");
if (data == null) {
throw new IllegalArgumentException("data is required.");
}
byte[] bytes;
try {
bytes = Base64.decode(data, Base64.DEFAULT);
} catch (IllegalArgumentException error) {
throw new IllegalArgumentException("data must be valid base64.", error);
}
String temporaryPath = path + ".tasknotes-write-" + UUID.randomUUID() + ".tmp";
DocumentFile temporary = fileForWrite(id, temporaryPath, mimeType(path));
DocumentFile file;
try {
try (
OutputStream raw = getContext().getContentResolver().openOutputStream(temporary.getUri(), "wt");
BufferedOutputStream output = raw == null ? null : new BufferedOutputStream(raw)
) {
if (output == null) {
throw new IOException("Could not open " + path + " for writing.");
}
output.write(bytes);
}
if (temporary.length() != bytes.length) {
throw new IOException("The provider wrote only part of " + path + ".");
}
DocumentFile existing = resolve(id, path);
if (existing != null && existing.exists()) {
throw new IOException("A binary already exists at " + path + ".");
}
pathCache.remove(path);
if (!temporary.renameTo(fileName(path))) {
throw new IOException("Could not commit " + path + ".");
}
pathCache.remove(temporaryPath);
file = resolve(id, path);
if (file == null || file.length() != bytes.length) {
throw new IOException("Committed binary could not be verified: " + path + ".");
}
} catch (Exception error) {
temporary.delete();
pathCache.remove(temporaryPath);
throw error;
}
JSObject response = new JSObject();
response.put("entry", entry(path, file));
return response;
});
}

@PluginMethod
public void rename(PluginCall call) {
run(call, () -> {
Expand Down Expand Up @@ -424,6 +502,10 @@ private DocumentFile requireFile(String id, String path) throws IOException {
}

private DocumentFile fileForWrite(String id, String path) throws IOException {
return fileForWrite(id, path, mimeType(path));
}

private DocumentFile fileForWrite(String id, String path, String mimeType) throws IOException {
DocumentFile existing = resolve(id, path);
if (existing != null) {
if (!existing.exists()) {
Expand All @@ -436,7 +518,7 @@ private DocumentFile fileForWrite(String id, String path) throws IOException {
}
String parent = parentPath(path);
DocumentFile directory = parent.isEmpty() ? requireRoot(id) : ensureDirectory(id, parent);
DocumentFile created = directory.createFile(mimeType(path), fileName(path));
DocumentFile created = directory.createFile(mimeType, fileName(path));
if (created == null) {
throw new IOException("Could not create " + path + ".");
}
Expand Down Expand Up @@ -472,6 +554,10 @@ private static JSObject entry(String path, DocumentFile file) {
entry.put("path", path);
entry.put("lastModified", file.lastModified());
entry.put("size", file.length());
String mediaType = file.getType();
if (mediaType != null) {
entry.put("mediaType", mediaType);
}
return entry;
}

Expand Down Expand Up @@ -543,6 +629,12 @@ private static String mimeType(String path) {
// unknown extension is created as text/plain.
return "application/octet-stream";
}
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".gif")) return "image/gif";
if (lower.endsWith(".avif")) return "image/avif";
if (lower.endsWith(".heic") || lower.endsWith(".heif")) return "image/heic";
return "text/plain";
}

Expand Down
43 changes: 43 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,49 @@ in React state.
are never exposed to application state or UI code.
10. A conflict blocks only its record. Other queued records continue syncing,
and the user can keep either the device or hosted version.
11. `attachments` in task frontmatter is authoritative for task membership.
Optional body embeds are presentation, while file descriptors are
authoritative for binary metadata; none is inferred from another.
12. Detaching a file is non-destructive. TaskNotes withholds permanent deletion
until a collection authority can atomically check every attachment list and
body embed and delete only still-unreferenced bytes.

## Attachments and local-first files

Task attachments use canonical collection-relative wiki links such as
`[[Attachments/receipt.jpg]]`. The task model validates and normalizes those
links without putting file metadata into YAML. Occurrence tasks inherit the
same references and never duplicate the underlying bytes.

Native Android and iOS collections store image bytes beside Markdown through
the same granted folder boundary. Listing derives portable descriptors with a
SHA-256 content digest and reuses them while path, modification time, and size
are unchanged. Attachment writes are journaled before binary work: bytes are
staged and verified first, then startup recovery completes frontmatter
membership. Native replacement is not advertised because Files providers do
not offer a portable atomic replace operation. An interruption can therefore
leave a recoverable extra file, never an attachment that quietly claims missing
bytes or an overwritten original. Browser-local attachment storage is
intentionally absent: browser collections use mdbase.

For mdbase collections, bytes are committed to the durable IndexedDB replica
and outbox before network work begins. Underlying file transport operations keep
stable transfer or mutation identities across restart, so retry is idempotent.
Reads prefer the device replica; reconciliation uploads pending work and fills
missing local bytes from the hosted authority. A pending-local state is shown
to the user but is never written into task frontmatter.

TaskNotes does not initiate physical attachment deletion for any provider. A
native folder can change outside the app, and an mdbase replica cannot prove
that another offline device has not created a reference. Neither authority yet
offers the required atomic reference-check-and-delete operation. Detach is
available and leaves safe orphan bytes; permanent cleanup must wait for that
authoritative transaction.

Collection adoption captures task records and file descriptors in one
authority snapshot, then transfers the corresponding bytes. A final snapshot
closes the edit window before cutover, using stable portable file identities so
retries cannot create duplicate attachments.

## Collection lifecycle

Expand Down
5 changes: 3 additions & 2 deletions e2e/cloud-connection.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { JsonObject } from "@mdbase-dev/connect";
import { buildTaskNotesMdbaseResources } from "@tasknotes/model/mdbase";
import { TASKNOTES_SPEC_VERSION } from "@tasknotes/model/types";
import { expect, test, type Route } from "@playwright/test";

import { TaskNotesTaskModel } from "../src/domain/tasknotes-model";
Expand Down Expand Up @@ -642,7 +643,7 @@ function collectionDescription() {
const implementation = type.implements.find(
(candidate) =>
candidate.contract === "tasknotes.task" &&
candidate.version === "0.3.0-rc.1",
candidate.version === TASKNOTES_SPEC_VERSION,
)!;
return {
protocol_version: 1,
Expand Down Expand Up @@ -674,7 +675,7 @@ function collectionDescription() {
{
contract_type: "record" as const,
id: "tasknotes.task",
version: "0.3.0-rc.1",
version: TASKNOTES_SPEC_VERSION,
digest: `sha256:${"0".repeat(64)}`,
schema: generated.taskSchema,
binding_schema: generated.bindingSchema,
Expand Down
15 changes: 14 additions & 1 deletion e2e/tasknotes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,20 @@ async function localTaskDocuments(page: Page): Promise<string[]> {
const documents: string[] = [];
for await (const [, handle] of tasks.entries()) {
if (handle.kind !== "file") continue;
documents.push(await (await handle.getFile()).text());
for (let attempt = 0; ; attempt += 1) {
try {
documents.push(await (await handle.getFile()).text());
break;
} catch (error) {
if (
!(error instanceof DOMException) ||
error.name !== "NotReadableError" ||
attempt >= 4
)
throw error;
await new Promise((resolve) => setTimeout(resolve, 40));
}
}
}
return documents;
});
Expand Down
32 changes: 32 additions & 0 deletions ios/App/App/FolderAccessPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ public class FolderAccessPlugin: CAPPlugin, CAPBridgedPlugin, UIDocumentPickerDe
CAPPluginMethod(name: "ensureDirectory", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "listFiles", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "readText", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "readBinary", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "writeText", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "writeBinary", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "rename", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "deleteFile", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "exists", returnType: CAPPluginReturnPromise)
Expand Down Expand Up @@ -257,6 +259,36 @@ public class FolderAccessPlugin: CAPPlugin, CAPBridgedPlugin, UIDocumentPickerDe
}
}

@objc public func readBinary(_ call: CAPPluginCall) {
perform(call, writing: false) { root in
let path = try self.requiredPath(call, key: "path")
let file = try self.url(root: root, path: path)
guard FileManager.default.fileExists(atPath: file.path) else {
throw FolderAccessError.notFound("File not found: \(path)")
}
return ["data": try Data(contentsOf: file).base64EncodedString()]
}
}

@objc public func writeBinary(_ call: CAPPluginCall) {
perform(call, writing: true) { root in
let path = try self.requiredPath(call, key: "path")
guard
let encoded = call.getString("data"),
let contents = Data(base64Encoded: encoded)
else {
throw FolderAccessError.invalidInput("data must be valid base64.")
}
let file = try self.url(root: root, path: path)
try FileManager.default.createDirectory(
at: file.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try contents.write(to: file, options: [.atomic, .withoutOverwriting])
return ["entry": try self.entry(path: path, url: file)]
}
}

@objc public func rename(_ call: CAPPluginCall) {
perform(call, writing: true) { root in
let from = try self.requiredPath(call, key: "from")
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"@mdbase-dev/connect": "0.1.0-beta.23",
"@mdbase-dev/connect-protocol": "0.1.0-beta.23",
"@mdbase-dev/connect-sync": "0.1.0-beta.23",
"@tasknotes/model": "file:vendor/tasknotes-model-0.3.0-rc.6.tgz",
"@tasknotes/model": "file:vendor/tasknotes-model-0.3.0-rc.9.tgz",
"dexie": "4.4.4",
"firebase": "12.16.0",
"lucide-react": "1.25.0",
Expand Down Expand Up @@ -91,7 +91,7 @@
"globals": "17.7.0",
"jsdom": "29.1.1",
"prettier": "3.9.6",
"tasknotes-spec": "file:vendor/tasknotes-spec-0.3.0-rc.1.tgz",
"tasknotes-spec": "file:vendor/tasknotes-spec-0.3.0-rc.3.tgz",
"typescript": "6.0.3",
"typescript-eslint": "8.65.0",
"vite": "8.1.5",
Expand Down
2 changes: 1 addition & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const webServerCommand =
export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
// Desktop and mobile exercise the same-origin OPFS collection.
// Desktop and mobile share the E2E-only OPFS fixture, never a product store.
workers: 1,
use: {
baseURL,
Expand Down
Loading