Keep using Photos.app like you always do. Attic quietly backs up your originals and edits to an S3 bucket you control. One-way, append-only.
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add edit detection to PhotoAsset (Phase 1)

Query ZUNMANAGEDADJUSTMENT and ZINTERNALRESOURCE from Photos.sqlite
to detect edited assets. Adds hasEdit, editedAt, and editor fields
to PhotoAsset, metadata JSON, and scan report.

+170 -2
+3
cli/src/commands/backup.test.ts
··· 31 31 albums: [], 32 32 keywords: [], 33 33 people: [], 34 + hasEdit: false, 35 + editedAt: null, 36 + editor: null, 34 37 ...overrides, 35 38 }; 36 39 }
+6
cli/src/commands/backup.ts
··· 57 57 albums: AlbumRef[]; 58 58 keywords: string[]; 59 59 people: PersonRef[]; 60 + hasEdit: boolean; 61 + editedAt: string | null; 62 + editor: string | null; 60 63 s3Key: string; 61 64 checksum: string; 62 65 backedUpAt: string; ··· 285 288 albums: asset.albums, 286 289 keywords: asset.keywords, 287 290 people: asset.people, 291 + hasEdit: asset.hasEdit, 292 + editedAt: asset.editedAt?.toISOString() ?? null, 293 + editor: asset.editor, 288 294 s3Key, 289 295 checksum: `sha256:${sha256}`, 290 296 backedUpAt: new Date().toISOString(),
+3 -1
cli/src/commands/scan.ts
··· 57 57 ` iCloud only: ${icloudOnly.length.toLocaleString()}\n`, 58 58 ); 59 59 60 - console.log(` Favorites: ${favorites.length.toLocaleString()}\n`); 60 + const edited = assets.filter((a) => a.hasEdit); 61 + console.log(` Favorites: ${favorites.length.toLocaleString()}`); 62 + console.log(` Edited: ${edited.length.toLocaleString()}\n`); 61 63 62 64 console.log(` Types:`); 63 65 for (const [type, count] of sortedTypes.slice(0, 10)) {
+94 -1
cli/src/photos-db/reader.test.ts
··· 31 31 ZORIGINALFILESIZE INTEGER, 32 32 ZORIGINALFILENAME TEXT, 33 33 ZORIGINALSTABLEHASH TEXT, 34 - ZTITLE TEXT 34 + ZTITLE TEXT, 35 + ZUNMANAGEDADJUSTMENT INTEGER 35 36 ); 36 37 `); 37 38 ··· 102 103 ZHIDDEN INTEGER DEFAULT 0, 103 104 ZASSETVISIBLE INTEGER DEFAULT 1 104 105 ); 106 + 107 + CREATE TABLE ZUNMANAGEDADJUSTMENT ( 108 + Z_PK INTEGER PRIMARY KEY, 109 + ZADJUSTMENTTIMESTAMP REAL, 110 + ZADJUSTMENTFORMATIDENTIFIER TEXT 111 + ); 112 + 113 + CREATE TABLE ZINTERNALRESOURCE ( 114 + Z_PK INTEGER PRIMARY KEY, 115 + ZASSET INTEGER, 116 + ZRESOURCETYPE INTEGER, 117 + ZTRASHEDSTATE INTEGER DEFAULT 0, 118 + ZVERSION INTEGER DEFAULT 0, 119 + ZDATALENGTH INTEGER, 120 + ZCOMPACTUTI TEXT, 121 + ZLOCALAVAILABILITY INTEGER DEFAULT 1 122 + ); 105 123 `); 106 124 107 125 if (opts?.withEnrichment) { ··· 138 156 INSERT INTO ZDETECTEDFACE (Z_PK, ZASSETFORFACE, ZPERSONFORFACE, ZHIDDEN, ZASSETVISIBLE) 139 157 VALUES (1, 1, 1, 0, 1); 140 158 `); 159 + 160 + // Edit data: photo asset (Z_PK=1) has an edit 161 + const editTs = 727012800 + 7200; // 2 hours after creation 162 + db.exec(` 163 + INSERT INTO ZUNMANAGEDADJUSTMENT (Z_PK, ZADJUSTMENTTIMESTAMP, ZADJUSTMENTFORMATIDENTIFIER) 164 + VALUES (1, ${editTs}, 'com.apple.photo'); 165 + 166 + UPDATE ZADDITIONALASSETATTRIBUTES SET ZUNMANAGEDADJUSTMENT = 1 WHERE Z_PK = 1; 167 + 168 + INSERT INTO ZINTERNALRESOURCE (Z_PK, ZASSET, ZRESOURCETYPE, ZTRASHEDSTATE, ZVERSION, ZDATALENGTH, ZCOMPACTUTI, ZLOCALAVAILABILITY) 169 + VALUES (1, 1, 1, 0, 1, 2048000, 'public.heic', 1); 170 + `); 141 171 } 142 172 } 143 173 ··· 192 222 assertEquals(photo.people.length, 1); 193 223 assertEquals(photo.people[0].displayName, "Alice"); 194 224 225 + // Edit fields 226 + assertEquals(photo.hasEdit, true); 227 + assertEquals(photo.editedAt?.toISOString(), "2024-01-15T14:00:00.000Z"); 228 + assertEquals(photo.editor, "com.apple.photo"); 229 + 195 230 const video = assets.find((a) => a.uuid === "uuid-video-1")!; 196 231 assertEquals(video.kind, AssetKind.VIDEO); 197 232 assertEquals(video.latitude, null); ··· 206 241 assertEquals(video.albums, []); 207 242 assertEquals(video.keywords, []); 208 243 assertEquals(video.people, []); 244 + 245 + // Video has no edit 246 + assertEquals(video.hasEdit, false); 247 + assertEquals(video.editedAt, null); 248 + assertEquals(video.editor, null); 209 249 } finally { 210 250 Deno.removeSync(dbPath); 211 251 } ··· 226 266 assertEquals(photo.albums, []); 227 267 assertEquals(photo.keywords, []); 228 268 assertEquals(photo.people, []); 269 + assertEquals(photo.hasEdit, false); 270 + assertEquals(photo.editedAt, null); 271 + assertEquals(photo.editor, null); 229 272 } finally { 230 273 Deno.removeSync(dbPath); 231 274 } 232 275 }); 276 + 277 + Deno.test("readAssets: adjustment without rendered resource yields hasEdit false", () => { 278 + const path = Deno.makeTempFileSync({ suffix: ".sqlite" }); 279 + const db = new Database(path); 280 + 281 + const coreDataTs = 727012800; 282 + 283 + db.exec(` 284 + CREATE TABLE ZASSET ( 285 + Z_PK INTEGER PRIMARY KEY, ZUUID TEXT, ZFILENAME TEXT, ZDIRECTORY TEXT, 286 + ZDATECREATED REAL, ZKIND INTEGER, ZUNIFORMTYPEIDENTIFIER TEXT, 287 + ZWIDTH INTEGER, ZHEIGHT INTEGER, ZLATITUDE REAL, ZLONGITUDE REAL, 288 + ZFAVORITE INTEGER, ZCLOUDLOCALSTATE INTEGER, ZTRASHEDSTATE INTEGER DEFAULT 0 289 + ); 290 + CREATE TABLE ZADDITIONALASSETATTRIBUTES ( 291 + Z_PK INTEGER PRIMARY KEY, ZASSET INTEGER, ZORIGINALFILESIZE INTEGER, 292 + ZORIGINALFILENAME TEXT, ZORIGINALSTABLEHASH TEXT, ZTITLE TEXT, 293 + ZUNMANAGEDADJUSTMENT INTEGER 294 + ); 295 + CREATE TABLE ZUNMANAGEDADJUSTMENT ( 296 + Z_PK INTEGER PRIMARY KEY, ZADJUSTMENTTIMESTAMP REAL, 297 + ZADJUSTMENTFORMATIDENTIFIER TEXT 298 + ); 299 + CREATE TABLE ZINTERNALRESOURCE ( 300 + Z_PK INTEGER PRIMARY KEY, ZASSET INTEGER, ZRESOURCETYPE INTEGER, 301 + ZTRASHEDSTATE INTEGER DEFAULT 0, ZVERSION INTEGER DEFAULT 0, 302 + ZDATALENGTH INTEGER, ZCOMPACTUTI TEXT, ZLOCALAVAILABILITY INTEGER DEFAULT 1 303 + ); 304 + 305 + INSERT INTO ZASSET VALUES (1, 'uuid-adj-only', 'IMG.HEIC', '/dir', ${coreDataTs}, 306 + ${AssetKind.PHOTO}, 'public.heic', 4032, 3024, NULL, NULL, 0, ${CloudLocalState.LOCAL}, 0); 307 + INSERT INTO ZADDITIONALASSETATTRIBUTES VALUES (1, 1, 1000, 'IMG.HEIC', 'hash', NULL, 1); 308 + INSERT INTO ZUNMANAGEDADJUSTMENT VALUES (1, ${coreDataTs + 100}, 'com.apple.photo'); 309 + `); 310 + // No ZINTERNALRESOURCE row — adjustment exists but no rendered file 311 + db.close(); 312 + 313 + try { 314 + const reader = openPhotosDb(path); 315 + const assets = reader.readAssets(); 316 + reader.close(); 317 + 318 + assertEquals(assets.length, 1); 319 + assertEquals(assets[0].hasEdit, false, "no rendered resource = hasEdit false"); 320 + assertEquals(assets[0].editedAt, null); 321 + assertEquals(assets[0].editor, null); 322 + } finally { 323 + Deno.removeSync(path); 324 + } 325 + });
+61
cli/src/photos-db/reader.ts
··· 104 104 return value as CloudLocalStateValue; 105 105 } 106 106 107 + interface EditInfo { 108 + editedAt: Date | null; 109 + editor: string; 110 + } 111 + 107 112 interface EnrichmentMaps { 108 113 descriptions: Map<number, string>; 109 114 albums: Map<number, AlbumRef[]>; 110 115 keywords: Map<number, string[]>; 111 116 people: Map<number, PersonRef[]>; 117 + edits: Map<number, EditInfo>; 118 + renderedAssets: Set<number>; 119 + } 120 + 121 + /** hasEdit requires both an adjustment record and a rendered resource. */ 122 + function editFields( 123 + enrichment: EnrichmentMaps, 124 + pk: number, 125 + ): Pick<PhotoAsset, "hasEdit" | "editedAt" | "editor"> { 126 + const editInfo = enrichment.edits.get(pk); 127 + const hasEdit = editInfo != null && enrichment.renderedAssets.has(pk); 128 + return { 129 + hasEdit, 130 + editedAt: hasEdit ? editInfo.editedAt : null, 131 + editor: hasEdit ? editInfo.editor : null, 132 + }; 112 133 } 113 134 114 135 function rowToAsset(row: RawRow, enrichment: EnrichmentMaps): PhotoAsset { ··· 134 155 albums: enrichment.albums.get(pk) ?? [], 135 156 keywords: enrichment.keywords.get(pk) ?? [], 136 157 people: enrichment.people.get(pk) ?? [], 158 + ...editFields(enrichment, pk), 137 159 }; 138 160 } 139 161 ··· 202 224 return map; 203 225 } 204 226 227 + function buildEditMap(db: Database): Map<number, EditInfo> { 228 + const rows = safeQuery<{ 229 + ZASSET: number; 230 + ZADJUSTMENTTIMESTAMP: number | null; 231 + ZADJUSTMENTFORMATIDENTIFIER: string; 232 + }>( 233 + db, 234 + `SELECT aa.ZASSET, ua.ZADJUSTMENTTIMESTAMP, ua.ZADJUSTMENTFORMATIDENTIFIER 235 + FROM ZADDITIONALASSETATTRIBUTES aa 236 + JOIN ZUNMANAGEDADJUSTMENT ua ON aa.ZUNMANAGEDADJUSTMENT = ua.Z_PK 237 + WHERE aa.ZUNMANAGEDADJUSTMENT IS NOT NULL 238 + AND ua.ZADJUSTMENTFORMATIDENTIFIER IS NOT NULL`, 239 + "edits", 240 + ); 241 + const map = new Map<number, EditInfo>(); 242 + for (const r of rows) { 243 + map.set(r.ZASSET, { 244 + editedAt: coreDataTimestampToDate(r.ZADJUSTMENTTIMESTAMP), 245 + editor: r.ZADJUSTMENTFORMATIDENTIFIER, 246 + }); 247 + } 248 + return map; 249 + } 250 + 251 + function buildRenderedAssetSet(db: Database): Set<number> { 252 + const rows = safeQuery<{ ZASSET: number }>( 253 + db, 254 + `SELECT DISTINCT ir.ZASSET 255 + FROM ZINTERNALRESOURCE ir 256 + WHERE ir.ZRESOURCETYPE = 1 257 + AND ir.ZTRASHEDSTATE = 0 258 + AND ir.ZVERSION != 0`, 259 + "rendered resources", 260 + ); 261 + return new Set(rows.map((r) => r.ZASSET)); 262 + } 263 + 205 264 function buildPeopleMap(db: Database): Map<number, PersonRef[]> { 206 265 const rows = safeQuery< 207 266 { ZASSETFORFACE: number; ZPERSONUUID: string; ZDISPLAYNAME: string } ··· 254 313 albums: buildAlbumMap(db), 255 314 keywords: buildKeywordMap(db), 256 315 people: buildPeopleMap(db), 316 + edits: buildEditMap(db), 317 + renderedAssets: buildRenderedAssetSet(db), 257 318 }; 258 319 259 320 return (rows as unknown as RawRow[]).map((row) =>
+3
shared/types.ts
··· 30 30 albums: AlbumRef[]; 31 31 keywords: string[]; 32 32 people: PersonRef[]; 33 + hasEdit: boolean; 34 + editedAt: Date | null; 35 + editor: string | null; 33 36 } 34 37 35 38 /** Cloud local state values from Photos.sqlite */