From 284fdc18bba97bd5dee5c44f2d2abf749f705bc8 Mon Sep 17 00:00:00 2001 From: penge Date: Tue, 26 Oct 2021 15:37:50 +0200 Subject: [PATCH] Add functionality to Export notes A) See Options page to Export all notes (a single ZIP file). B) Right-click on a note in the Sidebar to export that specific note only (HTML file). --- src/notes.tsx | 7 ++- src/notes/components/ContextMenu.tsx | 8 ++-- src/notes/export/index.ts | 31 +++++++++++++ src/options.tsx | 12 +++++ src/options/Export.tsx | 31 +++++++++++++ src/options/Font.tsx | 4 +- src/options/Options.tsx | 2 +- src/shared/download/index.ts | 17 +++++++ static/options.css | 66 ++++++++++++++-------------- 9 files changed, 138 insertions(+), 40 deletions(-) create mode 100644 src/notes/export/index.ts create mode 100644 src/options/Export.tsx create mode 100644 src/shared/download/index.ts diff --git a/src/notes.tsx b/src/notes.tsx index 39ed7b99..d5483774 100644 --- a/src/notes.tsx +++ b/src/notes.tsx @@ -38,6 +38,7 @@ import { sendMessage } from "messages"; import notesHistory from "notes/history"; import keyboardShortcuts, { KeyboardShortcut } from "notes/keyboard-shortcuts"; import { Command, commands } from "notes/commands"; +import { exportNote } from "notes/export"; const getFocusOverride = (): boolean => new URL(window.location.href).searchParams.get("focus") === ""; const getActiveFromUrl = (): string => new URL(window.location.href).searchParams.get("note") || ""; // Bookmark @@ -628,10 +629,14 @@ const Notes = (): h.JSX.Element => { }); }, locked: notesProps.notes[noteName].locked ?? false, - toggleLocked: (noteName) => { + onToggleLocked: (noteName) => { setContextMenuProps(null); tabId && notesRef.current && setLocked(noteName, !(notesProps.notes[noteName].locked ?? false), tabId, notesRef.current); }, + onExport: (noteName) => { + setContextMenuProps(null); + exportNote(noteName); + }, })} onNewNote={() => onNewNote()} sync={sync} diff --git a/src/notes/components/ContextMenu.tsx b/src/notes/components/ContextMenu.tsx index 3f84df55..6fbbb297 100644 --- a/src/notes/components/ContextMenu.tsx +++ b/src/notes/components/ContextMenu.tsx @@ -9,11 +9,12 @@ export interface ContextMenuProps { onRename: (noteName: string) => void onDelete: (noteName: string) => void locked: boolean - toggleLocked: (noteName: string) => void + onToggleLocked: (noteName: string) => void + onExport: (noteName: string) => void } const ContextMenu = ({ - noteName, x, y, onRename, onDelete, locked, toggleLocked, + noteName, x, y, onRename, onDelete, locked, onToggleLocked, onExport, }: ContextMenuProps): h.JSX.Element => { const [offsetHeight, setOffsetHeight] = useState(0); const ref = useRef(null); @@ -40,7 +41,8 @@ const ContextMenu = ({ }}>
!locked && onRename(noteName)}>Rename
!locked && onDelete(noteName)}>Delete
-
toggleLocked(noteName)}>{locked ? "Unlock" : "Lock"}
+
onToggleLocked(noteName)}>{locked ? "Unlock" : "Lock"}
+
onExport(noteName)}>Export
); }; diff --git a/src/notes/export/index.ts b/src/notes/export/index.ts new file mode 100644 index 00000000..3d9c7328 --- /dev/null +++ b/src/notes/export/index.ts @@ -0,0 +1,31 @@ +import { zipSync, Zippable } from "fflate"; +import { NotesObject } from "shared/storage/schema"; +import { downloadBlob } from "shared/download"; + +export const exportNote = (noteName: string): void => chrome.storage.local.get("notes", (local) => { + const { notes } = local as { notes: NotesObject }; + if (!(noteName in notes)) { + return; // there is no note to export + } + + const encoder = new TextEncoder(); + const data = encoder.encode(notes[noteName].content); + + downloadBlob(data, `${noteName}.html`, "text/html"); +}); + +export const exportNotes = (): void => chrome.storage.local.get("notes", (local) => { + const { notes } = local as { notes: NotesObject }; + if (!Object.keys(notes).length) { + return; // there are no notes to export + } + + const encoder = new TextEncoder(); + const data: Zippable = Object.keys(notes).reduce((acc, curr) => { + acc[`${curr}.html`] = encoder.encode(notes[curr].content); + return acc; + }, {} as Zippable); + + const gzipped = zipSync(data, { level: 0 }); + downloadBlob(gzipped, "notes.zip", "application/zip"); +}); diff --git a/src/options.tsx b/src/options.tsx index ffcf563e..29b169f3 100644 --- a/src/options.tsx +++ b/src/options.tsx @@ -6,11 +6,13 @@ import __Size from "options/Size"; import __Theme from "options/Theme"; import __KeyboardShortcuts from "options/KeyboardShortcuts"; import __Options from "options/Options"; +import __Export from "options/Export"; import __Version from "options/Version"; import { Os, Storage, + NotesObject, RegularFont, GoogleFont, Theme, @@ -21,6 +23,7 @@ import { setTheme as setThemeCore } from "themes/set-theme"; const Options = (): h.JSX.Element => { const [os, setOs] = useState(undefined); const [version] = useState(chrome.runtime.getManifest().version); + const [notesCount, setNotesCount] = useState(0); const [font, setFont] = useState(undefined); const [size, setSize] = useState(0); const [theme, setTheme] = useState(undefined); @@ -34,6 +37,7 @@ const Options = (): h.JSX.Element => { chrome.runtime.getPlatformInfo((platformInfo) => setOs(platformInfo.os === "mac" ? "mac" : "other")); chrome.storage.local.get([ + "notes", "font", "size", "theme", @@ -45,6 +49,7 @@ const Options = (): h.JSX.Element => { ], items => { const local = items as Storage; + setNotesCount(Object.keys(local.notes).length); setFont(local.font); setSize(local.size); setTheme(local.theme); @@ -60,6 +65,12 @@ const Options = (): h.JSX.Element => { return; } + if (changes["notes"]) { + const newValue: NotesObject = changes["notes"].newValue; + const newNotesCount = Object.keys(newValue).length; + setNotesCount(newNotesCount); + } + if (changes["font"]) { setFont(changes["font"].newValue); } @@ -116,6 +127,7 @@ const Options = (): h.JSX.Element => { tab={tab} tabSize={tabSize} /> + <__Export canExport={notesCount > 0} /> <__Version version={version} /> ); diff --git a/src/options/Export.tsx b/src/options/Export.tsx new file mode 100644 index 00000000..5b2b1915 --- /dev/null +++ b/src/options/Export.tsx @@ -0,0 +1,31 @@ +import { h, Fragment } from "preact"; +import clsx from "clsx"; +import { exportNotes } from "notes/export"; + +let locked = false; + +interface ExportProps { + canExport: boolean +} + +const Export = ({ canExport }: ExportProps): h.JSX.Element => ( + +

Export

+ { + if (!canExport || locked) { + return; + } + + locked = true; + exportNotes(); + window.setTimeout(() => locked = false, 1000); + }} + /> +
+); + +export default Export; diff --git a/src/options/Font.tsx b/src/options/Font.tsx index 2979cedd..3a15f0f5 100644 --- a/src/options/Font.tsx +++ b/src/options/Font.tsx @@ -88,6 +88,7 @@ const Font = ({ font }: FontProps): h.JSX.Element => { { setGoogleFontName((event.target as HTMLInputElement).value); @@ -96,8 +97,7 @@ const Font = ({ font }: FontProps): h.JSX.Element => { /> { const trimmedGoogleFontName = googleFontName.trim(); diff --git a/src/options/Options.tsx b/src/options/Options.tsx index 3ee27e2c..1d501d06 100644 --- a/src/options/Options.tsx +++ b/src/options/Options.tsx @@ -94,7 +94,7 @@ const Options = ({ sync, autoSync, tab, tabSize }: OptionsProps): h.JSX.Element
- { const newTabSize: number = parseInt((event.target as HTMLSelectElement).value); chrome.storage.local.set({ tabSize: newTabSize }); }}> diff --git a/src/shared/download/index.ts b/src/shared/download/index.ts new file mode 100644 index 00000000..2986b3db --- /dev/null +++ b/src/shared/download/index.ts @@ -0,0 +1,17 @@ +export const downloadURL = (data: string, fileName: string): void => { + const a = document.createElement("a"); + a.href = data; + a.download = fileName; + a.click(); + a.remove(); +}; + +export const downloadBlob = (data: Uint8Array, fileName: string, mimeType: string): void => { + const blob = new Blob([data], { + type: mimeType, + }); + + const url = window.URL.createObjectURL(blob); + downloadURL(url, fileName); + window.setTimeout(() => window.URL.revokeObjectURL(url), 2000); +}; diff --git a/static/options.css b/static/options.css index c44e5155..cbddd36a 100644 --- a/static/options.css +++ b/static/options.css @@ -23,13 +23,38 @@ input[type="radio"], input[type='checkbox'] { margin: 0 10px 0 2px; } -select { border-radius: 3px; } - .separator { padding: 0 12px; } .bold { font-weight: bold; } .space-top { margin-top: 1em; } .space-left { margin-left: 1em; } +.disabled { + opacity: .3; + user-select: none; + pointer-events: none; +} + +/* Inputs */ + +.button { + cursor: pointer; +} + +.button, .input, .select { + max-width: 500px; + box-sizing: content-box; + outline: none; + border: 1px solid silver; + padding: 12px; + border-radius: 3px; +} + +#dark .button, +#dark .input, +#dark .select { + border-color: transparent; +} + /* Selection */ .selection { padding: 10px 0; } @@ -65,9 +90,6 @@ body#dark .comment { .font-category { cursor: pointer; } .font-category.active { text-decoration: underline; } -#submit { opacity: .3; } -#submit.active { opacity: 1; cursor: pointer; } - .font-area .selection { font-size: 80%; display: flex; @@ -82,26 +104,14 @@ body#dark .comment { #google-fonts-area ol { margin-top: 0; } #google-fonts-area ol li { line-height: 2em; } -#google-fonts-area input, select { - max-width: 500px; - box-sizing: content-box; - outline: none; - border: 1px solid silver; - padding: 12px; -} - -#dark #google-fonts-area input, select { - border-color: transparent; +#google-fonts-area .input { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } -#google-fonts-area input[type="text"] { - border-top-left-radius: 3px; - border-top-right-radius: 3px; -} - -#google-fonts-area input[type="submit"] { - border-bottom-left-radius: 3px; - border-bottom-right-radius: 3px; +#google-fonts-area .button { + border-top-left-radius: 0; + border-top-right-radius: 0; } /* Font size */ @@ -189,16 +199,6 @@ body#dark .comment { word-break: break-all; } -div.disabled { - opacity: .3; - user-select: none; -} - -div.disabled input, -div.disabled select { - pointer-events: none; -} - /* Media Queries */ @media only screen and (max-width: 600px) {