Skip to content

Commit 4da99d6

Browse files
committed
Support annotations on local files
1 parent c677a24 commit 4da99d6

10 files changed

Lines changed: 219 additions & 62 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ bun run dev
2121

2222
Run `bun test` and `bun run build` before submitting changes.
2323

24+
For local `file://` pages, enable **Allow access to file URLs** in App Notes' Chrome extension settings, then reload the file.
25+
2426
See the [release checklist](docs/RELEASE_CHECKLIST.md) and [privacy policy](PRIVACY.md).
2527

2628
## License

‎docs/RELEASE_CHECKLIST.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ Test on Chrome Stable, Firefox Stable, Edge Stable, and current Arc. Test Safari
7777
### Compatibility and quality
7878

7979
-[ ] Test a conventional multi-page site, a React/Vue SPA, Yahoo, Hacker News, and a page with open Shadow DOM.
80+
-[ ] In Chrome, enable **Allow access to file URLs**, annotate a local HTML file, save and reload it, then confirm sibling HTML files share one folder workspace while a different folder remains isolated.
8081
-[ ] Restricted browser pages fail gracefully without broken controls.
8182
-[ ] Switch the operating system/browser between light and dark appearance; popup, composer, toast, markers, and notes workspace update automatically and remain legible on both light and dark host pages.
8283
-[ ] Keyboard focus order, visible focus, screen-reader labels, and 200% zoom are usable.

‎entrypoints/content/App.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
identifyElement,
1313
resolveAnnotationAnchor,
1414
}from'@/lib/anchoring';
15-
import{getAnnotationStorageKeyForUrl}from'@/lib/page';
15+
import{getAnnotationStorageKeyForUrl,getPageDisplayLabel}from'@/lib/page';
1616
import{getAnnotations,saveAnnotation}from'@/lib/storage';
1717
import{parseAnnotations}from'@/lib/types';
1818
importtype{Annotation,AnnotationAnchor}from'@/lib/types';
@@ -68,7 +68,9 @@ function getCurrentPageTitle(href: string): string {
6868
if(title)returntitle.slice(0,160);
6969

7070
try{
71-
returnnewURL(href).hostname.slice(0,160);
71+
constparsed=newURL(href);
72+
constfallback=parsed.hostname||getPageDisplayLabel(href)||'Untitled page';
73+
returnfallback.slice(0,160);
7274
}catch{
7375
return'Untitled page';
7476
}

‎entrypoints/popup/App.tsx‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@ function parseAnnotationModeResponse(value: unknown): AnnotationModeResponse | n
1919
returntypeofactive==='boolean' ? { active } : null;
2020
}
2121

22+
functiongetUnavailablePageMessage(url: string): string{
23+
try{
24+
if(newURL(url).protocol==='file:'){
25+
return'Allow file access in the extension settings, then reload this file.';
26+
}
27+
}catch{
28+
// Fall through to the general unavailable-page message.
29+
}
30+
31+
return'App Notes isn’t available on this page.';
32+
}
33+
2234
functionApp(){
2335
const[active,setActive]=useState(false);
2436
const[count,setCount]=useState(0);
@@ -64,7 +76,7 @@ function App() {
6476
setModeAvailable(true);
6577
}
6678
}catch{
67-
if(!cancelled)setStatus('App Notes isn’t available on this page.');
79+
if(!cancelled)setStatus(getUnavailablePageMessage(tab.url));
6880
}
6981
};
7082

@@ -113,7 +125,7 @@ function App() {
113125
}
114126
setActive(response.active);
115127
}catch{
116-
setStatus('App Notes isn’t available on this page.');
128+
setStatus(getUnavailablePageMessage(currentUrl));
117129
}finally{
118130
setIsToggling(false);
119131
}

‎entrypoints/sidepanel/App.tsx‎

Lines changed: 13 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import {
1414
}from'lucide-react';
1515
import{
1616
getAnnotationStoragePrefixForUrl,
17+
getPageDisplayLabel,
18+
getSiteDisplayLabel,
19+
parseSiteId,
1720
}from'@/lib/page';
1821
import{
1922
clearSiteAnnotations,
@@ -356,13 +359,7 @@ export function SidePanelApp() {
356359
constgroups=buildPanelGroups(annotations);
357360
constglobalAnnotations=allAnnotations??[];
358361
constpageUnavailable=site!==null&&site.storagePrefix===null;
359-
constdomain=(()=>{
360-
try{
361-
returnsite ? newURL(site.href).host : 'Current site';
362-
}catch{
363-
return'Current site';
364-
}
365-
})();
362+
constdomain=site ? getSiteDisplayLabel(site.href)??'Current site' : 'Current site';
366363

367364
return(
368365
<mainclassName="flex h-screen flex-col bg-surface">
@@ -758,14 +755,13 @@ function buildGlobalGroups(
758755
): ReadonlyArray<GlobalSiteGroup>{
759756
constgroups=newMap<string,{label: string;annotations: Annotation[]}>();
760757
for(constannotationofannotations){
761-
try{
762-
constparsed=newURL(annotation.url);
763-
constgroup=groups.get(parsed.origin)??{label: parsed.host,annotations: []};
764-
group.annotations.push(annotation);
765-
groups.set(parsed.origin,group);
766-
}catch{
767-
// Persisted annotations are parsed before reaching this view.
768-
}
758+
constsiteId=parseSiteId(annotation.url);
759+
constlabel=getSiteDisplayLabel(annotation.url);
760+
if(siteId===null||label===null)continue;
761+
762+
constgroup=groups.get(siteId)??{ label,annotations: []};
763+
group.annotations.push(annotation);
764+
groups.set(siteId,group);
769765
}
770766

771767
return[...groups.entries()]
@@ -803,12 +799,7 @@ function buildPanelGroups(
803799
}
804800

805801
functiongetPageLabel(pageId: string): string{
806-
try{
807-
constpath=newURL(pageId).pathname;
808-
returnpath==='/' ? 'Home' : path;
809-
}catch{
810-
returnpageId;
811-
}
802+
returngetPageDisplayLabel(pageId)??pageId;
812803
}
813804

814805
functiongetAnnotationPageLabel(annotation: Annotation): string{
@@ -833,11 +824,6 @@ function getAnnotationSummary(annotation: Annotation): string {
833824
}
834825

835826
functiongetExportFilename(url: string): string{
836-
letsite='site';
837-
try{
838-
site=newURL(url).hostname.replace(/[^a-z0-9.-]+/gi,'-');
839-
}catch{
840-
// Keep the safe fallback filename.
841-
}
827+
constsite=(getSiteDisplayLabel(url)??'site').replace(/[^a-z0-9.-]+/gi,'-');
842828
return`app-notes-${site}-${newDate().toISOString().slice(0,10)}.md`;
843829
}

‎lib/page.test.ts‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
getAnnotationStorageKeyForUrl,
55
getAnnotationStoragePrefix,
66
getAnnotationStoragePrefixForUrl,
7+
getPageDisplayLabel,
8+
getSiteDisplayLabel,
79
parsePageId,
810
parseSiteId,
911
}from'./page';
@@ -57,10 +59,53 @@ describe('page identity', () => {
5759
);
5860
});
5961

62+
test('uses the canonical local file URL as a page identity',()=>{
63+
consturl='file:///Users/ramos/workspaces/design/My%20Review/index.html?mode=edit#hero';
64+
65+
expect(parsePageId(url)?.toString()).toBe(
66+
'file:///Users/ramos/workspaces/design/My%20Review/index.html',
67+
);
68+
expect(getAnnotationStorageKeyForUrl(url)).toBe(
69+
'annotations:file:///Users/ramos/workspaces/design/My%20Review/index.html',
70+
);
71+
expect(getPageDisplayLabel(url)).toBe('index.html');
72+
});
73+
74+
test('groups local files in the same parent folder as one site',()=>{
75+
constindexUrl='file:///Users/ramos/workspaces/design/guided-review/index.html';
76+
constdetailsUrl='file:///Users/ramos/workspaces/design/guided-review/details.html';
77+
78+
expect(parseSiteId(indexUrl)?.toString()).toBe(
79+
'file:///Users/ramos/workspaces/design/guided-review',
80+
);
81+
expect(getAnnotationStoragePrefixForUrl(indexUrl)).toBe(
82+
'annotations:file:///Users/ramos/workspaces/design/guided-review/',
83+
);
84+
expect(getAnnotationStoragePrefixForUrl(detailsUrl)).toBe(
85+
getAnnotationStoragePrefixForUrl(indexUrl),
86+
);
87+
expect(getSiteDisplayLabel(indexUrl)).toBe('guided-review');
88+
});
89+
90+
test('keeps local file folders isolated',()=>{
91+
expect(
92+
getAnnotationStoragePrefixForUrl('file:///Users/ramos/project-a/index.html'),
93+
).not.toBe(
94+
getAnnotationStoragePrefixForUrl('file:///Users/ramos/project-b/index.html'),
95+
);
96+
});
97+
98+
test('handles files stored at the filesystem root',()=>{
99+
expect(getAnnotationStoragePrefixForUrl('file:///review.html')).toBe(
100+
'annotations:file:///',
101+
);
102+
expect(getSiteDisplayLabel('file:///review.html')).toBe('Local files');
103+
});
104+
60105
test('rejects unsupported and malformed URLs',()=>{
61106
expect(parsePageId('not a url')).toBeNull();
62107
expect(parsePageId('chrome://extensions')).toBeNull();
63-
expect(parsePageId('file:///tmp/page.html')).toBeNull();
108+
expect(parsePageId('data:text/html,hello')).toBeNull();
64109
expect(parseSiteId('chrome://extensions')).toBeNull();
65110
});
66111
});

‎lib/page.ts‎

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
declareconstpageIdBrand: unique symbol;
22
declareconstsiteIdBrand: unique symbol;
33

4-
/** The canonical identity of one annotatable web page. */
4+
/** The canonical identity of one annotatable web or local-file page. */
55
exporttypePageId=string&{readonly[pageIdBrand]: 'PageId'};
66

7-
/** The canonical identity of one annotatable website (its URL origin). */
7+
/** The canonical identity of one web origin or local-file folder workspace. */
88
exporttypeSiteId=string&{readonly[siteIdBrand]: 'SiteId'};
99

1010
/** The browser storage key containing annotations for one page. */
@@ -14,45 +14,94 @@ export type AnnotationStorageKey = `annotations:${string}`;
1414
exporttypeAnnotationStoragePrefix= `annotations:${string}/`;
1515

1616
/**
17-
* Parse an HTTP(S) URL into the page identity used by App Notes.
17+
* Parse an HTTP(S) or local-file URL into the page identity used by App Notes.
1818
*
1919
* Query parameters and fragments are intentionally excluded so transient URL
2020
* state does not split annotations for the same origin and pathname.
2121
*/
2222
exportfunctionparsePageId(url: string): PageId|null{
2323
try{
2424
constparsed=newURL(url);
25-
if(parsed.protocol!=='http:'&&parsed.protocol!=='https:')returnnull;
25+
if(!isAnnotatableProtocol(parsed.protocol))returnnull;
2626

27-
constpageId=`${parsed.origin}${parsed.pathname}`;
28-
// SAFETY: URL parsing established an absolute HTTP(S) origin and pathname.
27+
parsed.search='';
28+
parsed.hash='';
29+
constpageId=parsed.protocol==='file:'
30+
? parsed.href
31+
: `${parsed.origin}${parsed.pathname}`;
32+
33+
// SAFETY: URL parsing and the protocol guard established a canonical,
34+
// absolute HTTP(S) or local-file page URL without transient state.
2935
returnpageIdasPageId;
3036
}catch{
3137
returnnull;
3238
}
3339
}
3440

35-
/** Parse an HTTP(S) URL into the website identity used by App Notes. */
41+
/** Parse a URL into its web-origin or local parent-folder workspace identity. */
3642
exportfunctionparseSiteId(url: string): SiteId|null{
3743
try{
3844
constparsed=newURL(url);
39-
if(parsed.protocol!=='http:'&&parsed.protocol!=='https:')returnnull;
45+
if(!isAnnotatableProtocol(parsed.protocol))returnnull;
46+
47+
if(parsed.protocol==='file:'){
48+
constdirectory=parsed.pathname.endsWith('/') ? parsed : newURL('.',parsed);
49+
directory.search='';
50+
directory.hash='';
51+
constsiteId=directory.pathname==='/'
52+
? directory.href
53+
: directory.href.slice(0,-1);
54+
55+
// SAFETY: URL parsing and the file protocol guard established a canonical
56+
// absolute directory URL. Non-root directories omit only the trailing slash.
57+
returnsiteIdasSiteId;
58+
}
4059

41-
// SAFETY: URL parsing established an absolute HTTP(S) origin.
60+
// SAFETY: URL parsing and the protocol guard established an absolute HTTP(S) origin.
4261
returnparsed.originasSiteId;
4362
}catch{
4463
returnnull;
4564
}
4665
}
4766

67+
/** Return a concise user-facing label for an annotatable site workspace. */
68+
exportfunctiongetSiteDisplayLabel(url: string): string|null{
69+
constsiteId=parseSiteId(url);
70+
if(siteId===null)returnnull;
71+
72+
constparsed=newURL(siteId);
73+
if(parsed.protocol!=='file:')returnparsed.host;
74+
if(parsed.pathname==='/')return'Local files';
75+
76+
constsegments=parsed.pathname.split('/').filter((segment)=>segment.length>0);
77+
constlastSegment=segments.at(-1);
78+
returnlastSegment===undefined ? 'Local files' : decodePathSegment(lastSegment);
79+
}
80+
81+
/** Return a concise user-facing label for an annotatable page. */
82+
exportfunctiongetPageDisplayLabel(url: string): string|null{
83+
constpageId=parsePageId(url);
84+
if(pageId===null)returnnull;
85+
86+
constparsed=newURL(pageId);
87+
if(parsed.protocol!=='file:')returnparsed.pathname==='/' ? 'Home' : parsed.pathname;
88+
if(parsed.pathname.endsWith('/'))return'Home';
89+
90+
constsegments=parsed.pathname.split('/').filter((segment)=>segment.length>0);
91+
constlastSegment=segments.at(-1);
92+
returnlastSegment===undefined ? 'Local file' : decodePathSegment(lastSegment);
93+
}
94+
4895
/** Return the browser storage key for a parsed page identity. */
4996
exportfunctiongetAnnotationStorageKey(pageId: PageId): AnnotationStorageKey{
5097
return`annotations:${pageId}`;
5198
}
5299

53100
/** Return the exact storage prefix shared by all pages on a website. */
54101
exportfunctiongetAnnotationStoragePrefix(siteId: SiteId): AnnotationStoragePrefix{
55-
return`annotations:${siteId}/`;
102+
constprefix=`annotations:${siteId}${siteId.endsWith('/') ? '' : '/'}`;
103+
// SAFETY: the conditional suffix guarantees exactly one trailing slash.
104+
returnprefixasAnnotationStoragePrefix;
56105
}
57106

58107
/** Parse a URL and return its annotation storage key, or null when unsupported. */
@@ -66,3 +115,15 @@ export function getAnnotationStoragePrefixForUrl(url: string): AnnotationStorage
66115
constsiteId=parseSiteId(url);
67116
returnsiteId===null ? null : getAnnotationStoragePrefix(siteId);
68117
}
118+
119+
functionisAnnotatableProtocol(protocol: string): boolean{
120+
returnprotocol==='http:'||protocol==='https:'||protocol==='file:';
121+
}
122+
123+
functiondecodePathSegment(segment: string): string{
124+
try{
125+
returndecodeURIComponent(segment);
126+
}catch{
127+
returnsegment;
128+
}
129+
}

‎lib/storage.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,31 @@ describe('annotation storage', () => {
112112
]);
113113
});
114114

115+
test('reads and clears local pages by their parent folder',async()=>{
116+
constarea=newInMemoryStorageArea();
117+
constindexUrl='file:///Users/ramos/workspaces/guided-review/index.html';
118+
constdetailsUrl='file:///Users/ramos/workspaces/guided-review/details.html?mode=edit';
119+
constnestedUrl='file:///Users/ramos/workspaces/guided-review/archive/old.html';
120+
constotherUrl='file:///Users/ramos/workspaces/other-review/index.html';
121+
area.seed(getRequiredKey(indexUrl),[annotationFixture('index','index note',indexUrl)]);
122+
area.seed(getRequiredKey(detailsUrl),[annotationFixture('details','details note',detailsUrl)]);
123+
area.seed(getRequiredKey(nestedUrl),[annotationFixture('nested','nested note',nestedUrl)]);
124+
area.seed(getRequiredKey(otherUrl),[annotationFixture('other','other note',otherUrl)]);
125+
conststorage=createAnnotationStorage(area,deterministicDependencies([]));
126+
127+
expect((awaitstorage.getSiteAnnotations(indexUrl)).map(({ id })=>id).sort()).toEqual([
128+
'details',
129+
'index',
130+
]);
131+
132+
expect(awaitstorage.execute({
133+
type: 'app-notes:annotation/clear-site',
134+
url: detailsUrl,
135+
})).toEqual({_tag: 'site-cleared',clearedPages: 2});
136+
expect((awaitstorage.getAnnotations(nestedUrl)).map(({ id })=>id)).toEqual(['nested']);
137+
expect((awaitstorage.getAnnotations(otherUrl)).map(({ id })=>id)).toEqual(['other']);
138+
});
139+
115140
test('reads valid annotations across every website',async()=>{
116141
constarea=newInMemoryStorageArea();
117142
constfinanceUrl='https://www.yahoo.com/finance';
@@ -290,6 +315,22 @@ describe('site Markdown export', () => {
290315
expect(markdown).toContain('**Note**\n\nInvestigate this architecture');
291316
expect(markdown).not.toContain('## /');
292317
});
318+
319+
test('exports local file workspaces with folder and file labels',()=>{
320+
constindexUrl='file:///Users/ramos/workspaces/guided-review/index.html';
321+
constdetailsUrl='file:///Users/ramos/workspaces/guided-review/details.html?mode=edit';
322+
constmarkdown=formatSiteAnnotationsMarkdown(indexUrl,[
323+
annotationFixture('index','Review the landing state',indexUrl),
324+
annotationFixture('details','Review the detail state',detailsUrl),
325+
]);
326+
327+
expect(markdown).toContain('# Notes for guided-review');
328+
expect(markdown).toContain('2 notes across 2 pages');
329+
expect(markdown).toContain('## index.html');
330+
expect(markdown).toContain('file:///Users/ramos/workspaces/guided-review/index.html');
331+
expect(markdown).toContain('## details.html');
332+
expect(markdown).not.toContain('?mode=edit');
333+
});
293334
});
294335

295336
classInMemoryStorageAreaimplementsAnnotationStorageArea{

0 commit comments

Comments
 (0)