mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 19:17:03 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e714c8997 | ||
|
|
cc26e1d51c | ||
|
|
530e260903 | ||
|
|
74a684498a | ||
|
|
3fccb17060 | ||
|
|
9acb3aae3c | ||
|
|
fb6f14165f | ||
|
|
c79897c3db | ||
|
|
5cc083d670 | ||
|
|
ecc94e6c31 | ||
|
|
baf1154683 | ||
|
|
7a2d7c6aff | ||
|
|
9a38976000 | ||
|
|
28367b6670 | ||
|
|
124c1947c1 |
No files matched your search
@@ -137,6 +137,19 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Provision Google credentials
|
||||
shell: bash
|
||||
env:
|
||||
GOOGLE_CREDENTIALS: ${{ secrets.GOOGLE_CREDENTIALS }}
|
||||
run: |
|
||||
if [ -n "$GOOGLE_CREDENTIALS" ]; then
|
||||
printf '%s' "$GOOGLE_CREDENTIALS" > google-credentials.json
|
||||
echo "Wrote google-credentials.json from GOOGLE_CREDENTIALS secret."
|
||||
else
|
||||
cp google-credentials.example.json google-credentials.json
|
||||
echo "::warning::GOOGLE_CREDENTIALS secret not set — embedding placeholder credentials; Google Drive backup will be disabled in this release."
|
||||
fi
|
||||
|
||||
- name: Build and upload
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
|
||||
@@ -26,6 +26,10 @@ dist-ssr
|
||||
# Tauri build output
|
||||
src-tauri/target/
|
||||
|
||||
# Google OAuth desktop client (real values, never commit)
|
||||
/google-credentials.json
|
||||
/client_secret_*.json
|
||||
|
||||
# Screenshots & Playwright MCP artifacts
|
||||
.playwright-mcp/
|
||||
/*.png
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## Git Commit Rules
|
||||
|
||||
- Do not make branches, commit in main only
|
||||
- Use conventional commit format: `feat|fix|refactor|docs|test|chore|ci(scope): message`
|
||||
- Never use `git add .` or `git add -A`. Always stage specific files by name.
|
||||
- Don't batch multiple unrelated changes into one commit.
|
||||
- Commit early and often. A working 5-line change is better than a pending 200-line change.
|
||||
@@ -1,37 +1,3 @@
|
||||
# margin
|
||||
|
||||
An offline desktop app for writing books and exporting them to print-ready PDF and reflowable EPUB — a focused, free alternative to InDesign for text-first books (novels, non-fiction).
|
||||
|
||||
## Stack
|
||||
|
||||
- **Tauri 2** (Rust shell) — offline by construction; the entire export pipeline runs locally, nothing hits the network.
|
||||
- **React + TipTap** — the semantic writing surface.
|
||||
- **Typst** (embedded as a Rust crate) — print-ready PDF with real book typography.
|
||||
- **Built-in EPUB3** generator + zip packager.
|
||||
|
||||
One semantic document feeds three renderers — the editor, the PDF, and the EPUB — from a single design-token theme (**Quiet Press**: Literata for the page, Hanken Grotesk for the chrome). Both fonts are bundled.
|
||||
|
||||
## Develop
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm tauri dev # the real desktop app: live PDF dock, file dialogs, PDF/EPUB export
|
||||
pnpm dev # browser-only UI preview (Tauri APIs are inert)
|
||||
```
|
||||
|
||||
The first `tauri dev` build is slow because it compiles Typst.
|
||||
|
||||
## Using it
|
||||
|
||||
- A book saves as a single `.margin` file (JSON). Open / Save with ⌘O / ⌘S.
|
||||
- Click the title in the top bar for **Book setup** (title, author, ISBN, trim size, language).
|
||||
- Insert images from the toolbar and choose a placement (inline / full-width / full-page / float).
|
||||
- Export to **PDF** or **EPUB** from the titlebar menu.
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/editor/` — TipTap editor, extensions, figure block, floating toolbar.
|
||||
- `src/components/` — app shell, preview dock, book-setup panel, pdf.js renderer.
|
||||
- `src/export/` — `typst.ts` (book → Typst source), `epub.ts` (book → EPUB3 files), `exporters.ts`.
|
||||
- `src/store/`, `src/model/` — document model and state.
|
||||
- `src-tauri/src/` — `pdf.rs` (Typst compile), `epub.rs` (zip packager), `project.rs` (file IO).
|
||||
Write your book. Own every word. A calm, offline studio to write, format, and publish your book to every store and to print.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"installed": {
|
||||
"client_id": "YOUR_CLIENT_ID.apps.googleusercontent.com",
|
||||
"project_id": "your-project-id",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_secret": "YOUR_CLIENT_SECRET",
|
||||
"redirect_uris": ["http://127.0.0.1"]
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "margin-app",
|
||||
"private": true,
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2018 The Fraunces Project Authors (https://github.com/undercasetype/Fraunces)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2014 The Source Serif 4 Project Authors (https://github.com/adobe-fonts/source-serif)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Generated
+135
-3
@@ -3089,8 +3089,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3754,6 +3756,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4632,6 +4635,12 @@ dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "lzma-rs"
|
||||
version = "0.3.0"
|
||||
@@ -4704,12 +4713,16 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||
|
||||
[[package]]
|
||||
name = "margin-app"
|
||||
version = "0.1.3"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"fontdb",
|
||||
"harper-core",
|
||||
"rand 0.8.6",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"spellbook",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
@@ -4720,6 +4733,7 @@ dependencies = [
|
||||
"typst",
|
||||
"typst-as-lib",
|
||||
"typst-pdf",
|
||||
"url",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
@@ -5966,6 +5980,61 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash 2.1.2",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.4",
|
||||
"ring",
|
||||
"rustc-hash 2.1.2",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@@ -5993,6 +6062,8 @@ version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
]
|
||||
@@ -6033,6 +6104,7 @@ version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -6203,6 +6275,44 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.4"
|
||||
@@ -6425,6 +6535,7 @@ version = "1.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -6773,6 +6884,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.21.0"
|
||||
@@ -7421,7 +7544,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -7610,7 +7733,7 @@ dependencies = [
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"reqwest 0.13.4",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
@@ -9149,6 +9272,15 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "margin-app"
|
||||
version = "0.1.4"
|
||||
version = "0.1.7"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -27,6 +27,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
zip = "2"
|
||||
fontdb = "0.23"
|
||||
typst = "0.14.2"
|
||||
typst-pdf = "0.14.2"
|
||||
typst-as-lib = "0.15.5"
|
||||
@@ -35,6 +36,10 @@ spellbook = "0.4"
|
||||
# bump could silently invalidate the patch ("unused") and pull the broken CUDA subtree back in,
|
||||
# so bump deliberately and re-audit the stubs. (see [patch.crates-io])
|
||||
harper-core = { version = "=2.5.0", features = ["concurrent"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||
sha2 = "0.10"
|
||||
rand = "0.8"
|
||||
url = "2"
|
||||
|
||||
# harper-core transitively declares an optional, disabled `burn-cuda` (CUDA backend).
|
||||
# Cargo still version-resolves that dead subtree, which currently fails (broken tracel-llvm
|
||||
|
||||
@@ -5,9 +5,6 @@ use std::io::{Read, Write};
|
||||
use zip::write::SimpleFileOptions;
|
||||
use zip::{CompressionMethod, ZipArchive, ZipWriter};
|
||||
|
||||
static LITERATA: &[u8] = include_bytes!("../../public/fonts/Literata-VF.ttf");
|
||||
static LITERATA_ITALIC: &[u8] = include_bytes!("../../public/fonts/Literata-Italic-VF.ttf");
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct EpubFile {
|
||||
path: String,
|
||||
@@ -48,14 +45,6 @@ fn build(files: &[EpubFile]) -> Result<Vec<u8>, String> {
|
||||
zip.write_all(&bytes).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
for (path, bytes) in [
|
||||
("OEBPS/fonts/Literata-VF.ttf", LITERATA),
|
||||
("OEBPS/fonts/Literata-Italic-VF.ttf", LITERATA_ITALIC),
|
||||
] {
|
||||
zip.start_file(path, deflated).map_err(|e| e.to_string())?;
|
||||
zip.write_all(bytes).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(zip.finish().map_err(|e| e.to_string())?.into_inner())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
use fontdb::{Database, Family, Query};
|
||||
|
||||
fn system_db() -> Database {
|
||||
let mut db = Database::new();
|
||||
db.load_system_fonts();
|
||||
db
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn list_system_fonts() -> Vec<String> {
|
||||
let db = system_db();
|
||||
let mut names: Vec<String> = db
|
||||
.faces()
|
||||
.filter_map(|face| face.families.first().map(|(name, _)| name.clone()))
|
||||
.collect();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
pub fn system_font_bytes(family: &str) -> Vec<Vec<u8>> {
|
||||
let db = system_db();
|
||||
let mut out = Vec::new();
|
||||
let styles = [
|
||||
(fontdb::Weight::NORMAL, fontdb::Style::Normal),
|
||||
(fontdb::Weight::NORMAL, fontdb::Style::Italic),
|
||||
(fontdb::Weight::BOLD, fontdb::Style::Normal),
|
||||
(fontdb::Weight::BOLD, fontdb::Style::Italic),
|
||||
];
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (weight, style) in styles {
|
||||
let query = Query {
|
||||
families: &[Family::Name(family)],
|
||||
weight,
|
||||
style,
|
||||
..Query::default()
|
||||
};
|
||||
if let Some(id) = db.query(&query) {
|
||||
if seen.insert(id) {
|
||||
if let Some(bytes) = db.with_face_data(id, |data, _index| data.to_vec()) {
|
||||
out.push(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use rand::RngCore;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
const FOLDER_NAME: &str = "margin";
|
||||
const DICTIONARY_NAME: &str = "custom-dictionary.txt";
|
||||
const SCOPES: &str = "openid email https://www.googleapis.com/auth/drive.file";
|
||||
const AUTH_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
const CREDENTIALS_JSON: &str =
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../google-credentials.json"));
|
||||
|
||||
static HTTP: LazyLock<reqwest::Client> = LazyLock::new(reqwest::Client::new);
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CredentialsFile {
|
||||
installed: Credentials,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Credentials {
|
||||
client_id: String,
|
||||
client_secret: String,
|
||||
auth_uri: String,
|
||||
token_uri: String,
|
||||
}
|
||||
|
||||
fn load_credentials() -> Result<Credentials, String> {
|
||||
let runtime_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../google-credentials.json");
|
||||
let raw = fs::read_to_string(runtime_path).unwrap_or_else(|_| CREDENTIALS_JSON.to_string());
|
||||
let parsed: CredentialsFile = serde_json::from_str(&raw)
|
||||
.map_err(|e| format!("invalid google-credentials.json: {e}"))?;
|
||||
let creds = parsed.installed;
|
||||
if creds.client_id.starts_with("YOUR_CLIENT_ID") || creds.client_secret.starts_with("YOUR_CLIENT_SECRET") {
|
||||
return Err("Google Drive is not set up yet. Add a real OAuth desktop client to google-credentials.json.".to_string());
|
||||
}
|
||||
Ok(creds)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GDriveState(pub Mutex<Session>);
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Session {
|
||||
refresh_token: Option<String>,
|
||||
access_token: Option<String>,
|
||||
access_expiry: u64,
|
||||
email: Option<String>,
|
||||
folder_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
struct BackupState {
|
||||
#[serde(default)]
|
||||
refresh_token: Option<String>,
|
||||
email: Option<String>,
|
||||
folder_id: Option<String>,
|
||||
last_backup: Option<u64>,
|
||||
#[serde(default)]
|
||||
files: HashMap<String, FileRecord>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
struct FileRecord {
|
||||
hash: String,
|
||||
drive_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Status {
|
||||
connected: bool,
|
||||
email: Option<String>,
|
||||
last_backup: Option<u64>,
|
||||
pending: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RestoreResult {
|
||||
restored: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BackupOutcome {
|
||||
uploaded: usize,
|
||||
#[serde(flatten)]
|
||||
status: Status,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteBackup {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AuthEvent {
|
||||
ok: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
fn now() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
|
||||
}
|
||||
|
||||
fn dictionary_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(crate::library::app_data_dir(app)?.join(DICTIONARY_NAME))
|
||||
}
|
||||
|
||||
fn state_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(crate::library::app_data_dir(app)?.join("backup.json"))
|
||||
}
|
||||
|
||||
fn load_state(app: &tauri::AppHandle) -> BackupState {
|
||||
let path = match state_path(app) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return BackupState::default(),
|
||||
};
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
|
||||
Err(_) => BackupState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_state(app: &tauri::AppHandle, state: &BackupState) -> Result<(), String> {
|
||||
let path = state_path(app)?;
|
||||
let contents = serde_json::to_string_pretty(state).map_err(|e| e.to_string())?;
|
||||
crate::project::atomic_write(&path, contents.as_bytes(), false)
|
||||
}
|
||||
|
||||
fn random_b64(bytes: usize) -> String {
|
||||
let mut buf = vec![0u8; bytes];
|
||||
rand::thread_rng().fill_bytes(&mut buf);
|
||||
URL_SAFE_NO_PAD.encode(buf)
|
||||
}
|
||||
|
||||
fn pkce_challenge(verifier: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(verifier.as_bytes());
|
||||
URL_SAFE_NO_PAD.encode(hasher.finalize())
|
||||
}
|
||||
|
||||
fn hash_bytes(bytes: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
fn urlencode(s: &str) -> String {
|
||||
url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
|
||||
}
|
||||
|
||||
fn drive_escape(s: &str) -> String {
|
||||
s.replace('\\', "\\\\").replace('\'', "\\'")
|
||||
}
|
||||
|
||||
fn safe_book_name(name: &str) -> bool {
|
||||
match name.strip_suffix(".margin") {
|
||||
Some(stem) => !stem.is_empty() && stem.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_http_message(stream: &mut TcpStream, message: &str) {
|
||||
let body = format!(
|
||||
"<!doctype html><html><head><meta charset=\"utf-8\"><title>margin</title></head>\
|
||||
<body style=\"font-family:system-ui,sans-serif;text-align:center;padding-top:80px;color:#222\">\
|
||||
<h2>{message}</h2></body></html>"
|
||||
);
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
let _ = stream.flush();
|
||||
}
|
||||
|
||||
fn await_code(listener: TcpListener, expected_state: &str, deadline: Instant) -> Result<String, String> {
|
||||
listener.set_nonblocking(true).map_err(|e| e.to_string())?;
|
||||
loop {
|
||||
if Instant::now() > deadline {
|
||||
return Err("Timed out waiting for Google authorization.".to_string());
|
||||
}
|
||||
match listener.accept() {
|
||||
Ok((mut stream, _)) => {
|
||||
stream.set_nonblocking(false).ok();
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
|
||||
let mut buf = [0u8; 8192];
|
||||
let n = stream.read(&mut buf).unwrap_or(0);
|
||||
let request = String::from_utf8_lossy(&buf[..n]);
|
||||
let path = request.lines().next().and_then(|line| line.split_whitespace().nth(1)).unwrap_or("");
|
||||
if path == "/favicon.ico" {
|
||||
write_http_message(&mut stream, "margin");
|
||||
continue;
|
||||
}
|
||||
let full = format!("http://127.0.0.1{path}");
|
||||
let parsed = match url::Url::parse(&full) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
write_http_message(&mut stream, "Waiting for Google…");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut code = None;
|
||||
let mut state = None;
|
||||
let mut error = None;
|
||||
for (key, value) in parsed.query_pairs() {
|
||||
match key.as_ref() {
|
||||
"code" => code = Some(value.into_owned()),
|
||||
"state" => state = Some(value.into_owned()),
|
||||
"error" => error = Some(value.into_owned()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(error) = error {
|
||||
write_http_message(&mut stream, "Authorization was cancelled. You can close this tab.");
|
||||
return Err(format!("Google authorization failed: {error}"));
|
||||
}
|
||||
match (code, state) {
|
||||
(Some(code), Some(state)) if state == expected_state => {
|
||||
write_http_message(&mut stream, "Connected to margin. You can close this tab.");
|
||||
return Ok(code);
|
||||
}
|
||||
(Some(_), _) => {
|
||||
write_http_message(&mut stream, "Could not verify the request. You can close this tab.");
|
||||
return Err("State mismatch during Google authorization.".to_string());
|
||||
}
|
||||
_ => {
|
||||
write_http_message(&mut stream, "Waiting for Google…");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
#[serde(default)]
|
||||
refresh_token: Option<String>,
|
||||
#[serde(default)]
|
||||
expires_in: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UserInfo {
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DriveFile {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FileList {
|
||||
#[serde(default)]
|
||||
files: Vec<DriveFile>,
|
||||
#[serde(default, rename = "nextPageToken")]
|
||||
next_page_token: Option<String>,
|
||||
}
|
||||
|
||||
async fn read_json<T: DeserializeOwned>(resp: reqwest::Response, context: &str) -> Result<T, String> {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.map_err(|e| e.to_string())?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("{context} failed ({status}): {text}"));
|
||||
}
|
||||
serde_json::from_str(&text).map_err(|e| format!("{context}: could not parse response: {e}"))
|
||||
}
|
||||
|
||||
async fn exchange_code(creds: &Credentials, code: &str, redirect: &str, verifier: &str) -> Result<TokenResponse, String> {
|
||||
let resp = HTTP
|
||||
.post(&creds.token_uri)
|
||||
.form(&[
|
||||
("client_id", creds.client_id.as_str()),
|
||||
("client_secret", creds.client_secret.as_str()),
|
||||
("code", code),
|
||||
("code_verifier", verifier),
|
||||
("grant_type", "authorization_code"),
|
||||
("redirect_uri", redirect),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
read_json(resp, "Google token exchange").await
|
||||
}
|
||||
|
||||
async fn refresh_access_token(creds: &Credentials, refresh_token: &str) -> Result<TokenResponse, String> {
|
||||
let resp = HTTP
|
||||
.post(&creds.token_uri)
|
||||
.form(&[
|
||||
("client_id", creds.client_id.as_str()),
|
||||
("client_secret", creds.client_secret.as_str()),
|
||||
("refresh_token", refresh_token),
|
||||
("grant_type", "refresh_token"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
read_json(resp, "Google token refresh").await
|
||||
}
|
||||
|
||||
async fn fetch_email(access_token: &str) -> Result<String, String> {
|
||||
let resp = HTTP
|
||||
.get("https://www.googleapis.com/oauth2/v2/userinfo")
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let info: UserInfo = read_json(resp, "Google account lookup").await?;
|
||||
Ok(info.email.unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn ensure_folder(access_token: &str) -> Result<String, String> {
|
||||
let query = format!(
|
||||
"name = '{}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false",
|
||||
drive_escape(FOLDER_NAME)
|
||||
);
|
||||
let resp = HTTP
|
||||
.get("https://www.googleapis.com/drive/v3/files")
|
||||
.bearer_auth(access_token)
|
||||
.query(&[
|
||||
("q", query.as_str()),
|
||||
("fields", "files(id,name)"),
|
||||
("spaces", "drive"),
|
||||
("pageSize", "1"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let list: FileList = read_json(resp, "Drive folder lookup").await?;
|
||||
if let Some(folder) = list.files.into_iter().next() {
|
||||
return Ok(folder.id);
|
||||
}
|
||||
let body = serde_json::json!({
|
||||
"name": FOLDER_NAME,
|
||||
"mimeType": "application/vnd.google-apps.folder",
|
||||
});
|
||||
let resp = HTTP
|
||||
.post("https://www.googleapis.com/drive/v3/files")
|
||||
.bearer_auth(access_token)
|
||||
.query(&[("fields", "id")])
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let created: DriveFile = read_json(resp, "Drive folder creation").await?;
|
||||
Ok(created.id)
|
||||
}
|
||||
|
||||
async fn find_file(access_token: &str, folder_id: &str, name: &str) -> Result<Option<DriveFile>, String> {
|
||||
let query = format!(
|
||||
"'{}' in parents and name = '{}' and trashed = false",
|
||||
drive_escape(folder_id),
|
||||
drive_escape(name)
|
||||
);
|
||||
let resp = HTTP
|
||||
.get("https://www.googleapis.com/drive/v3/files")
|
||||
.bearer_auth(access_token)
|
||||
.query(&[
|
||||
("q", query.as_str()),
|
||||
("fields", "files(id,name)"),
|
||||
("spaces", "drive"),
|
||||
("pageSize", "1"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let list: FileList = read_json(resp, "Drive file lookup").await?;
|
||||
Ok(list.files.into_iter().next())
|
||||
}
|
||||
|
||||
async fn list_in_folder(access_token: &str, folder_id: &str) -> Result<Vec<DriveFile>, String> {
|
||||
let query = format!("'{}' in parents and trashed = false", drive_escape(folder_id));
|
||||
let mut files = Vec::new();
|
||||
let mut page_token: Option<String> = None;
|
||||
loop {
|
||||
let mut request = HTTP
|
||||
.get("https://www.googleapis.com/drive/v3/files")
|
||||
.bearer_auth(access_token)
|
||||
.query(&[
|
||||
("q", query.as_str()),
|
||||
("fields", "nextPageToken,files(id,name)"),
|
||||
("spaces", "drive"),
|
||||
("pageSize", "100"),
|
||||
]);
|
||||
if let Some(token) = &page_token {
|
||||
request = request.query(&[("pageToken", token.as_str())]);
|
||||
}
|
||||
let resp = request.send().await.map_err(|e| e.to_string())?;
|
||||
let FileList { files: page, next_page_token } = read_json(resp, "Drive file list").await?;
|
||||
files.extend(page);
|
||||
match next_page_token {
|
||||
Some(token) => page_token = Some(token),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
async fn upload_file(
|
||||
access_token: &str,
|
||||
folder_id: &str,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
existing_id: Option<String>,
|
||||
) -> Result<DriveFile, String> {
|
||||
let boundary = "margin7f3e2a1b9c8d";
|
||||
let metadata = match &existing_id {
|
||||
Some(_) => serde_json::json!({ "name": name }),
|
||||
None => serde_json::json!({ "name": name, "parents": [folder_id] }),
|
||||
};
|
||||
let mut body: Vec<u8> = Vec::new();
|
||||
body.extend_from_slice(format!("--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n").as_bytes());
|
||||
body.extend_from_slice(serde_json::to_string(&metadata).unwrap_or_default().as_bytes());
|
||||
body.extend_from_slice(format!("\r\n--{boundary}\r\nContent-Type: application/octet-stream\r\n\r\n").as_bytes());
|
||||
body.extend_from_slice(bytes);
|
||||
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
|
||||
|
||||
let request = match &existing_id {
|
||||
Some(id) => HTTP.patch(format!("https://www.googleapis.com/upload/drive/v3/files/{id}")),
|
||||
None => HTTP.post("https://www.googleapis.com/upload/drive/v3/files"),
|
||||
};
|
||||
let resp = request
|
||||
.bearer_auth(access_token)
|
||||
.query(&[("uploadType", "multipart"), ("fields", "id")])
|
||||
.header(reqwest::header::CONTENT_TYPE, format!("multipart/related; boundary={boundary}"))
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
read_json(resp, &format!("Drive upload of {name}")).await
|
||||
}
|
||||
|
||||
async fn download_file(access_token: &str, id: &str) -> Result<Vec<u8>, String> {
|
||||
let resp = HTTP
|
||||
.get(format!("https://www.googleapis.com/drive/v3/files/{id}"))
|
||||
.bearer_auth(access_token)
|
||||
.query(&[("alt", "media")])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Drive download failed ({status}): {text}"));
|
||||
}
|
||||
Ok(resp.bytes().await.map_err(|e| e.to_string())?.to_vec())
|
||||
}
|
||||
|
||||
async fn valid_access_token(app: &tauri::AppHandle, state: &GDriveState) -> Result<String, String> {
|
||||
let (access, expiry, refresh) = {
|
||||
let session = state.0.lock().unwrap();
|
||||
(session.access_token.clone(), session.access_expiry, session.refresh_token.clone())
|
||||
};
|
||||
if let Some(token) = access {
|
||||
if now() < expiry {
|
||||
return Ok(token);
|
||||
}
|
||||
}
|
||||
let refresh = refresh.ok_or("Not connected to Google Drive.")?;
|
||||
let creds = load_credentials()?;
|
||||
let tokens = refresh_access_token(&creds, &refresh).await?;
|
||||
let access_token = tokens.access_token.clone();
|
||||
{
|
||||
let mut session = state.0.lock().unwrap();
|
||||
session.access_token = Some(tokens.access_token);
|
||||
session.access_expiry = now() + tokens.expires_in.saturating_sub(60);
|
||||
if let Some(rotated) = &tokens.refresh_token {
|
||||
session.refresh_token = Some(rotated.clone());
|
||||
}
|
||||
}
|
||||
if let Some(rotated) = &tokens.refresh_token {
|
||||
let mut stored = load_state(app);
|
||||
stored.refresh_token = Some(rotated.clone());
|
||||
let _ = save_state(app, &stored);
|
||||
}
|
||||
Ok(access_token)
|
||||
}
|
||||
|
||||
async fn ensure_folder_id(app: &tauri::AppHandle, state: &GDriveState, access_token: &str) -> Result<String, String> {
|
||||
{
|
||||
let session = state.0.lock().unwrap();
|
||||
if let Some(folder) = &session.folder_id {
|
||||
return Ok(folder.clone());
|
||||
}
|
||||
}
|
||||
let folder_id = ensure_folder(access_token).await?;
|
||||
{
|
||||
let mut session = state.0.lock().unwrap();
|
||||
session.folder_id = Some(folder_id.clone());
|
||||
}
|
||||
let mut stored = load_state(app);
|
||||
stored.folder_id = Some(folder_id.clone());
|
||||
save_state(app, &stored)?;
|
||||
Ok(folder_id)
|
||||
}
|
||||
|
||||
fn collect_local_files(app: &tauri::AppHandle) -> Result<Vec<(String, Vec<u8>)>, String> {
|
||||
let mut files = Vec::new();
|
||||
let library = crate::library::library_dir(app)?;
|
||||
for entry in fs::read_dir(&library).map_err(|e| e.to_string())? {
|
||||
let path = match entry {
|
||||
Ok(entry) => entry.path(),
|
||||
Err(_) => continue,
|
||||
};
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("margin") {
|
||||
continue;
|
||||
}
|
||||
let name = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(name) if safe_book_name(name) => name.to_string(),
|
||||
_ => continue,
|
||||
};
|
||||
let bytes = fs::read(&path).map_err(|e| e.to_string())?;
|
||||
files.push((name, bytes));
|
||||
}
|
||||
let dictionary = dictionary_path(app)?;
|
||||
if dictionary.exists() {
|
||||
let bytes = fs::read(&dictionary).map_err(|e| e.to_string())?;
|
||||
files.push((DICTIONARY_NAME.to_string(), bytes));
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn file_pending(path: &std::path::Path, name: &str, stored: &BackupState, last_backup: u64) -> bool {
|
||||
let record = match stored.files.get(name) {
|
||||
Some(record) => record,
|
||||
None => return true,
|
||||
};
|
||||
let touched = fs::metadata(path)
|
||||
.and_then(|meta| meta.modified())
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|since| since.as_secs() > last_backup)
|
||||
.unwrap_or(true);
|
||||
if !touched {
|
||||
return false;
|
||||
}
|
||||
match fs::read(path) {
|
||||
Ok(bytes) => record.hash != hash_bytes(&bytes),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_pending(app: &tauri::AppHandle, stored: &BackupState) -> bool {
|
||||
let last_backup = stored.last_backup.unwrap_or(0);
|
||||
let library = match crate::library::library_dir(app) {
|
||||
Ok(dir) => dir,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if let Ok(entries) = fs::read_dir(&library) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("margin") {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if safe_book_name(name) && file_pending(&path, name, stored, last_backup) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match dictionary_path(app) {
|
||||
Ok(dict) if dict.exists() => file_pending(&dict, DICTIONARY_NAME, stored, last_backup),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn status_inner(app: &tauri::AppHandle, state: &GDriveState) -> Status {
|
||||
let (connected, email) = {
|
||||
let session = state.0.lock().unwrap();
|
||||
(session.refresh_token.is_some(), session.email.clone())
|
||||
};
|
||||
let stored = load_state(app);
|
||||
let pending = connected && compute_pending(app, &stored);
|
||||
Status {
|
||||
connected,
|
||||
email: email.or(stored.email),
|
||||
last_backup: stored.last_backup,
|
||||
pending,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_session(app: &tauri::AppHandle) {
|
||||
let state = app.state::<GDriveState>();
|
||||
let stored = load_state(app);
|
||||
let mut session = state.0.lock().unwrap();
|
||||
session.refresh_token = stored.refresh_token;
|
||||
session.email = stored.email;
|
||||
session.folder_id = stored.folder_id;
|
||||
}
|
||||
|
||||
async fn complete_auth(
|
||||
app: &tauri::AppHandle,
|
||||
listener: TcpListener,
|
||||
csrf: String,
|
||||
verifier: String,
|
||||
redirect: String,
|
||||
creds: Credentials,
|
||||
) -> Result<(), String> {
|
||||
let code = tauri::async_runtime::spawn_blocking(move || {
|
||||
let deadline = Instant::now() + Duration::from_secs(AUTH_TIMEOUT_SECS);
|
||||
await_code(listener, &csrf, deadline)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
let tokens = exchange_code(&creds, &code, &redirect, &verifier).await?;
|
||||
let email = fetch_email(&tokens.access_token).await?;
|
||||
let folder_id = ensure_folder(&tokens.access_token).await?;
|
||||
|
||||
{
|
||||
let state = app.state::<GDriveState>();
|
||||
let mut session = state.0.lock().unwrap();
|
||||
if let Some(refresh) = &tokens.refresh_token {
|
||||
session.refresh_token = Some(refresh.clone());
|
||||
}
|
||||
session.access_token = Some(tokens.access_token.clone());
|
||||
session.access_expiry = now() + tokens.expires_in.saturating_sub(60);
|
||||
session.email = Some(email.clone());
|
||||
session.folder_id = Some(folder_id.clone());
|
||||
}
|
||||
|
||||
let mut stored = load_state(app);
|
||||
if let Some(refresh) = &tokens.refresh_token {
|
||||
stored.refresh_token = Some(refresh.clone());
|
||||
}
|
||||
stored.email = Some(email);
|
||||
stored.folder_id = Some(folder_id);
|
||||
save_state(app, &stored)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn gdrive_connect(app: tauri::AppHandle) -> Result<String, String> {
|
||||
let creds = load_credentials()?;
|
||||
let verifier = random_b64(64);
|
||||
let challenge = pkce_challenge(&verifier);
|
||||
let csrf = random_b64(24);
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
|
||||
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
|
||||
let redirect = format!("http://127.0.0.1:{port}");
|
||||
|
||||
let auth_url = format!(
|
||||
"{}?client_id={}&redirect_uri={}&response_type=code&scope={}&code_challenge={}&code_challenge_method=S256&state={}&access_type=offline&prompt=consent",
|
||||
creds.auth_uri,
|
||||
urlencode(&creds.client_id),
|
||||
urlencode(&redirect),
|
||||
urlencode(SCOPES),
|
||||
challenge,
|
||||
urlencode(&csrf),
|
||||
);
|
||||
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
let _ = app.opener().open_url(auth_url.clone(), None::<&str>);
|
||||
|
||||
let app_bg = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let event = match complete_auth(&app_bg, listener, csrf, verifier, redirect, creds).await {
|
||||
Ok(()) => AuthEvent { ok: true, error: None },
|
||||
Err(error) => AuthEvent { ok: false, error: Some(error) },
|
||||
};
|
||||
let _ = app_bg.emit("gdrive-auth", event);
|
||||
});
|
||||
|
||||
Ok(auth_url)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn gdrive_disconnect(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<Status, String> {
|
||||
let refresh = {
|
||||
let session = state.0.lock().unwrap();
|
||||
session.refresh_token.clone()
|
||||
};
|
||||
if let Some(refresh) = refresh {
|
||||
let _ = HTTP
|
||||
.post("https://oauth2.googleapis.com/revoke")
|
||||
.form(&[("token", refresh.as_str())])
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
{
|
||||
let mut session = state.0.lock().unwrap();
|
||||
session.refresh_token = None;
|
||||
session.access_token = None;
|
||||
session.access_expiry = 0;
|
||||
session.email = None;
|
||||
}
|
||||
let mut stored = load_state(&app);
|
||||
stored.refresh_token = None;
|
||||
stored.email = None;
|
||||
save_state(&app, &stored)?;
|
||||
Ok(status_inner(&app, &state))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn gdrive_status(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<Status, String> {
|
||||
Ok(status_inner(&app, &state))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<BackupOutcome, String> {
|
||||
let access_token = valid_access_token(&app, &state).await?;
|
||||
let folder_id = ensure_folder_id(&app, &state, &access_token).await?;
|
||||
let files = collect_local_files(&app)?;
|
||||
let mut stored = load_state(&app);
|
||||
let mut uploaded = 0;
|
||||
for (name, bytes) in &files {
|
||||
let hash = hash_bytes(bytes);
|
||||
let existing = stored.files.get(name).cloned();
|
||||
if let Some(record) = &existing {
|
||||
if record.hash == hash {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let drive_id = match &existing {
|
||||
Some(record) => Some(record.drive_id.clone()),
|
||||
None => find_file(&access_token, &folder_id, name).await?.map(|file| file.id),
|
||||
};
|
||||
let result = upload_file(&access_token, &folder_id, name, bytes, drive_id).await?;
|
||||
stored.files.insert(name.clone(), FileRecord { hash, drive_id: result.id });
|
||||
uploaded += 1;
|
||||
}
|
||||
if uploaded > 0 {
|
||||
stored.last_backup = Some(now());
|
||||
save_state(&app, &stored)?;
|
||||
}
|
||||
Ok(BackupOutcome {
|
||||
uploaded,
|
||||
status: Status {
|
||||
connected: true,
|
||||
email: stored.email.clone(),
|
||||
last_backup: stored.last_backup,
|
||||
pending: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn gdrive_restore(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<RestoreResult, String> {
|
||||
let access_token = valid_access_token(&app, &state).await?;
|
||||
let folder_id = ensure_folder_id(&app, &state, &access_token).await?;
|
||||
let remote = list_in_folder(&access_token, &folder_id).await?;
|
||||
let mut stored = load_state(&app);
|
||||
let mut restored = 0;
|
||||
for file in &remote {
|
||||
let is_dictionary = file.name == DICTIONARY_NAME;
|
||||
let is_book = safe_book_name(&file.name);
|
||||
if !is_dictionary && !is_book {
|
||||
continue;
|
||||
}
|
||||
let bytes = download_file(&access_token, &file.id).await?;
|
||||
let destination = if is_dictionary {
|
||||
dictionary_path(&app)?
|
||||
} else {
|
||||
crate::library::library_dir(&app)?.join(&file.name)
|
||||
};
|
||||
crate::project::atomic_write(&destination, &bytes, false)?;
|
||||
stored.files.insert(
|
||||
file.name.clone(),
|
||||
FileRecord { hash: hash_bytes(&bytes), drive_id: file.id.clone() },
|
||||
);
|
||||
restored += 1;
|
||||
}
|
||||
stored.last_backup = Some(now());
|
||||
save_state(&app, &stored)?;
|
||||
Ok(RestoreResult { restored })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn gdrive_list_backups(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<Vec<RemoteBackup>, String> {
|
||||
let access_token = valid_access_token(&app, &state).await?;
|
||||
let folder_id = ensure_folder_id(&app, &state, &access_token).await?;
|
||||
let remote = list_in_folder(&access_token, &folder_id).await?;
|
||||
Ok(remote
|
||||
.into_iter()
|
||||
.filter(|file| safe_book_name(&file.name))
|
||||
.map(|file| RemoteBackup { name: file.name })
|
||||
.collect())
|
||||
}
|
||||
+15
-1
@@ -1,4 +1,6 @@
|
||||
mod epub;
|
||||
mod fonts;
|
||||
mod gdrive;
|
||||
mod library;
|
||||
mod pdf;
|
||||
mod project;
|
||||
@@ -71,6 +73,11 @@ pub fn run() {
|
||||
|
||||
builder
|
||||
.manage(proofing::new_state())
|
||||
.manage(gdrive::GDriveState::default())
|
||||
.setup(|app| {
|
||||
gdrive::init_session(app.handle());
|
||||
Ok(())
|
||||
})
|
||||
.menu(|handle| build_menu(handle))
|
||||
.on_menu_event(|app, event| {
|
||||
if matches!(
|
||||
@@ -83,6 +90,7 @@ pub fn run() {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
epub::package_epub,
|
||||
epub::unzip_epub,
|
||||
fonts::list_system_fonts,
|
||||
pdf::compile_pdf,
|
||||
project::read_file,
|
||||
project::write_file,
|
||||
@@ -92,7 +100,13 @@ pub fn run() {
|
||||
library::save_book,
|
||||
library::delete_book,
|
||||
proofing::proof_text,
|
||||
proofing::remember_word
|
||||
proofing::remember_word,
|
||||
gdrive::gdrive_connect,
|
||||
gdrive::gdrive_disconnect,
|
||||
gdrive::gdrive_status,
|
||||
gdrive::gdrive_backup,
|
||||
gdrive::gdrive_restore,
|
||||
gdrive::gdrive_list_backups
|
||||
])
|
||||
.run(context)
|
||||
.expect("error while running margin");
|
||||
|
||||
@@ -19,12 +19,14 @@ fn corrupt_summary(stem: &str) -> BookSummary {
|
||||
}
|
||||
}
|
||||
|
||||
fn library_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("library");
|
||||
pub(crate) fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
pub(crate) fn library_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app_data_dir(app)?.join("library");
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
+48
-3
@@ -10,6 +10,29 @@ static LITERATA: &[u8] = include_bytes!("../../public/fonts/Literata-VF.ttf");
|
||||
static LITERATA_ITALIC: &[u8] = include_bytes!("../../public/fonts/Literata-Italic-VF.ttf");
|
||||
static HANKEN: &[u8] = include_bytes!("../../public/fonts/HankenGrotesk-VF.ttf");
|
||||
static HANKEN_ITALIC: &[u8] = include_bytes!("../../public/fonts/HankenGrotesk-Italic-VF.ttf");
|
||||
static EB_GARAMOND: &[u8] = include_bytes!("../../public/fonts/EBGaramond-VF.ttf");
|
||||
static EB_GARAMOND_ITALIC: &[u8] = include_bytes!("../../public/fonts/EBGaramond-Italic-VF.ttf");
|
||||
static LORA: &[u8] = include_bytes!("../../public/fonts/Lora-VF.ttf");
|
||||
static LORA_ITALIC: &[u8] = include_bytes!("../../public/fonts/Lora-Italic-VF.ttf");
|
||||
static SOURCE_SERIF: &[u8] = include_bytes!("../../public/fonts/SourceSerif4-VF.ttf");
|
||||
static SOURCE_SERIF_ITALIC: &[u8] = include_bytes!("../../public/fonts/SourceSerif4-Italic-VF.ttf");
|
||||
static FRAUNCES: &[u8] = include_bytes!("../../public/fonts/Fraunces-VF.ttf");
|
||||
static FRAUNCES_ITALIC: &[u8] = include_bytes!("../../public/fonts/Fraunces-Italic-VF.ttf");
|
||||
|
||||
static EB_GARAMOND_FONTS: [&[u8]; 2] = [EB_GARAMOND, EB_GARAMOND_ITALIC];
|
||||
static LORA_FONTS: [&[u8]; 2] = [LORA, LORA_ITALIC];
|
||||
static SOURCE_SERIF_FONTS: [&[u8]; 2] = [SOURCE_SERIF, SOURCE_SERIF_ITALIC];
|
||||
static FRAUNCES_FONTS: [&[u8]; 2] = [FRAUNCES, FRAUNCES_ITALIC];
|
||||
|
||||
fn bundled_font_bytes(id: &str) -> &'static [&'static [u8]] {
|
||||
match id {
|
||||
"eb-garamond" => &EB_GARAMOND_FONTS,
|
||||
"lora" => &LORA_FONTS,
|
||||
"source-serif" => &SOURCE_SERIF_FONTS,
|
||||
"fraunces" => &FRAUNCES_FONTS,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ImageInput {
|
||||
@@ -17,7 +40,12 @@ pub struct ImageInput {
|
||||
data: String,
|
||||
}
|
||||
|
||||
fn compile(source: String, images: &[ImageInput]) -> Result<(Vec<u8>, String), String> {
|
||||
fn compile(
|
||||
source: String,
|
||||
images: &[ImageInput],
|
||||
bundled_fonts: &[String],
|
||||
system_fonts: &[String],
|
||||
) -> Result<(Vec<u8>, String), String> {
|
||||
let mut binaries: Vec<(&str, Vec<u8>)> = Vec::with_capacity(images.len());
|
||||
for image in images {
|
||||
let bytes = STANDARD
|
||||
@@ -26,9 +54,24 @@ fn compile(source: String, images: &[ImageInput]) -> Result<(Vec<u8>, String), S
|
||||
binaries.push((image.path.as_str(), bytes));
|
||||
}
|
||||
|
||||
let mut fonts: Vec<Vec<u8>> = vec![
|
||||
LITERATA.to_vec(),
|
||||
LITERATA_ITALIC.to_vec(),
|
||||
HANKEN.to_vec(),
|
||||
HANKEN_ITALIC.to_vec(),
|
||||
];
|
||||
for id in bundled_fonts {
|
||||
for bytes in bundled_font_bytes(id) {
|
||||
fonts.push(bytes.to_vec());
|
||||
}
|
||||
}
|
||||
for family in system_fonts {
|
||||
fonts.extend(crate::fonts::system_font_bytes(family));
|
||||
}
|
||||
|
||||
let engine = TypstEngine::builder()
|
||||
.main_file(source)
|
||||
.fonts([LITERATA, LITERATA_ITALIC, HANKEN, HANKEN_ITALIC])
|
||||
.fonts(fonts)
|
||||
.with_static_file_resolver(binaries)
|
||||
.build();
|
||||
|
||||
@@ -50,8 +93,10 @@ pub fn compile_pdf(
|
||||
source: String,
|
||||
images: Vec<ImageInput>,
|
||||
emit_warnings: bool,
|
||||
bundled_fonts: Vec<String>,
|
||||
system_fonts: Vec<String>,
|
||||
) -> Result<tauri::ipc::Response, String> {
|
||||
let (bytes, warnings) = compile(source, &images)?;
|
||||
let (bytes, warnings) = compile(source, &images, &bundled_fonts, &system_fonts)?;
|
||||
if emit_warnings && !warnings.is_empty() {
|
||||
app.emit("pdf-warnings", warnings).ok();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "margin",
|
||||
"version": "0.1.4",
|
||||
"productName": "Margin",
|
||||
"version": "0.1.7",
|
||||
"identifier": "studio.margin.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
+39
-3
@@ -3,7 +3,9 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { Library } from "./components/Library";
|
||||
import { EditorView } from "./components/EditorView";
|
||||
import { BackupSettings } from "./components/BackupSettings";
|
||||
import { useBook } from "./store/useBook";
|
||||
import { useBackup } from "./store/useBackup";
|
||||
import { isDesktop } from "./ipc";
|
||||
import { createAndOpenBook, saveBook } from "./library";
|
||||
import { runExport } from "./export/run";
|
||||
@@ -37,9 +39,16 @@ function App() {
|
||||
const win = getCurrentWindow();
|
||||
const unlisten = win.onCloseRequested(async (event) => {
|
||||
const { book, dirty } = useBook.getState();
|
||||
if (!book || !dirty) return;
|
||||
const { connected } = useBackup.getState();
|
||||
if ((!book || !dirty) && !connected) return;
|
||||
event.preventDefault();
|
||||
await saveBook(book).catch(() => {});
|
||||
if (book && dirty) await saveBook(book).catch(() => {});
|
||||
if (connected) {
|
||||
await Promise.race([
|
||||
useBackup.getState().backup(true),
|
||||
new Promise((resolve) => setTimeout(resolve, 8000)),
|
||||
]);
|
||||
}
|
||||
win.destroy();
|
||||
});
|
||||
return () => {
|
||||
@@ -47,7 +56,34 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return book ? <EditorView /> : <Library onOpen={openBook} />;
|
||||
useEffect(() => {
|
||||
if (!isDesktop) return;
|
||||
const unlisten = listen<{ ok: boolean; error: string | null }>("gdrive-auth", (event) => {
|
||||
useBackup.getState().handleAuthEvent(event.payload.ok, event.payload.error);
|
||||
});
|
||||
return () => {
|
||||
unlisten.then((stop) => stop());
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktop) return;
|
||||
const tick = async () => {
|
||||
await useBackup.getState().refresh();
|
||||
const state = useBackup.getState();
|
||||
if (state.connected && state.pending) state.backup(true);
|
||||
};
|
||||
tick();
|
||||
const id = setInterval(tick, 15 * 60 * 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{book ? <EditorView /> : <Library onOpen={openBook} />}
|
||||
{isDesktop && <BackupSettings />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { isDesktop } from "./ipc";
|
||||
|
||||
export interface BackupStatus {
|
||||
connected: boolean;
|
||||
email: string | null;
|
||||
lastBackup: number | null;
|
||||
pending: boolean;
|
||||
}
|
||||
|
||||
export interface RestoreResult {
|
||||
restored: number;
|
||||
}
|
||||
|
||||
export interface BackupOutcome extends BackupStatus {
|
||||
uploaded: number;
|
||||
}
|
||||
|
||||
export interface RemoteBackup {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const OFFLINE: BackupStatus = { connected: false, email: null, lastBackup: null, pending: false };
|
||||
|
||||
export async function gdriveStatus(): Promise<BackupStatus> {
|
||||
if (!isDesktop) return OFFLINE;
|
||||
return invoke<BackupStatus>("gdrive_status");
|
||||
}
|
||||
|
||||
export async function gdriveConnect(): Promise<string> {
|
||||
return invoke<string>("gdrive_connect");
|
||||
}
|
||||
|
||||
export async function gdriveDisconnect(): Promise<BackupStatus> {
|
||||
return invoke<BackupStatus>("gdrive_disconnect");
|
||||
}
|
||||
|
||||
export async function gdriveBackup(): Promise<BackupOutcome> {
|
||||
return invoke<BackupOutcome>("gdrive_backup");
|
||||
}
|
||||
|
||||
export async function gdriveRestore(): Promise<RestoreResult> {
|
||||
return invoke<RestoreResult>("gdrive_restore");
|
||||
}
|
||||
|
||||
export async function gdriveListBackups(): Promise<RemoteBackup[]> {
|
||||
if (!isDesktop) return [];
|
||||
return invoke<RemoteBackup[]>("gdrive_list_backups");
|
||||
}
|
||||
|
||||
export function formatBackupTime(ts: number | null): string {
|
||||
if (!ts) return "Not backed up yet";
|
||||
const diff = Date.now() - ts * 1000;
|
||||
const min = Math.floor(diff / 60000);
|
||||
if (min < 1) return "Backed up just now";
|
||||
if (min < 60) return `Backed up ${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `Backed up ${hr}h ago`;
|
||||
const day = Math.floor(hr / 24);
|
||||
if (day === 1) return "Backed up yesterday";
|
||||
if (day < 7) return `Backed up ${day}d ago`;
|
||||
return `Backed up ${new Date(ts * 1000).toLocaleDateString()}`;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { type BookFonts, fontStack } from "./model/fonts";
|
||||
|
||||
export function applyBookFonts(fonts: BookFonts): void {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty("--font-book", fontStack(fonts.body));
|
||||
root.style.setProperty("--font-heading", fontStack(fonts.heading));
|
||||
}
|
||||
|
||||
export function resetBookFonts(): void {
|
||||
const root = document.documentElement;
|
||||
root.style.removeProperty("--font-book");
|
||||
root.style.removeProperty("--font-heading");
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Icon } from "./Icon";
|
||||
import { useBackup } from "../store/useBackup";
|
||||
import { formatBackupTime } from "../backup";
|
||||
|
||||
export function BackupButton() {
|
||||
const connected = useBackup((s) => s.connected);
|
||||
const pending = useBackup((s) => s.pending);
|
||||
const phase = useBackup((s) => s.phase);
|
||||
const lastBackup = useBackup((s) => s.lastBackup);
|
||||
const openSettings = useBackup((s) => s.openSettings);
|
||||
|
||||
const busy = phase === "working" || phase === "connecting";
|
||||
const error = phase === "error";
|
||||
|
||||
const state = busy ? "working" : error ? "error" : !connected ? "off" : pending ? "pending" : "synced";
|
||||
|
||||
const title =
|
||||
phase === "connecting"
|
||||
? "Waiting for Google authorization…"
|
||||
: state === "working"
|
||||
? "Backing up…"
|
||||
: state === "error"
|
||||
? "Backup needs attention"
|
||||
: state === "off"
|
||||
? "Connect Google Drive"
|
||||
: state === "pending"
|
||||
? "Changes not backed up"
|
||||
: formatBackupTime(lastBackup);
|
||||
|
||||
const onClick = () => {
|
||||
openSettings();
|
||||
};
|
||||
|
||||
return (
|
||||
<button className="icon-btn backup-btn" data-state={state} onClick={onClick} title={title}>
|
||||
{busy ? (
|
||||
<Icon size={18}>
|
||||
<path d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5" />
|
||||
</Icon>
|
||||
) : (
|
||||
<Icon size={18}>
|
||||
<path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.6-1.3A3.5 3.5 0 0 1 18 18H7z" />
|
||||
{connected ? <path d="M12 16.5v-5M9.7 12.8l2.3-2.3 2.3 2.3" /> : null}
|
||||
</Icon>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Icon } from "./Icon";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
import { useBackup } from "../store/useBackup";
|
||||
import { formatBackupTime, gdriveListBackups } from "../backup";
|
||||
|
||||
export function BackupSettings() {
|
||||
const open = useBackup((s) => s.settingsOpen);
|
||||
const close = useBackup((s) => s.closeSettings);
|
||||
const connected = useBackup((s) => s.connected);
|
||||
const email = useBackup((s) => s.email);
|
||||
const lastBackup = useBackup((s) => s.lastBackup);
|
||||
const pending = useBackup((s) => s.pending);
|
||||
const phase = useBackup((s) => s.phase);
|
||||
const connect = useBackup((s) => s.connect);
|
||||
const disconnect = useBackup((s) => s.disconnect);
|
||||
const backup = useBackup((s) => s.backup);
|
||||
const restore = useBackup((s) => s.restore);
|
||||
const cancelConnect = useBackup((s) => s.cancelConnect);
|
||||
const openAuthUrl = useBackup((s) => s.openAuthUrl);
|
||||
const copyAuthUrl = useBackup((s) => s.copyAuthUrl);
|
||||
|
||||
const [remoteCount, setRemoteCount] = useState<number | null>(null);
|
||||
const [confirmRestore, setConfirmRestore] = useState(false);
|
||||
const working = phase === "working";
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !connected) {
|
||||
setRemoteCount(null);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
gdriveListBackups()
|
||||
.then((list) => active && setRemoteCount(list.length))
|
||||
.catch(() => active && setRemoteCount(null));
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [open, connected, lastBackup]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overlay" onClick={close}>
|
||||
<div className="panel" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="panel-head">
|
||||
<h2>Backup & Sync</h2>
|
||||
<button className="icon-btn" onClick={close} title="Close">
|
||||
<Icon d="M6 6l12 12M18 6L6 18" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
{phase === "connecting" ? (
|
||||
<div className="backup-connecting">
|
||||
<div className="backup-waiting">
|
||||
<Icon size={16}>
|
||||
<path d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5" />
|
||||
</Icon>
|
||||
<span>Waiting for Google authorization…</span>
|
||||
</div>
|
||||
<p>Finish in the browser tab that opened. If it didn't open, reopen the link or copy it into a browser.</p>
|
||||
<div className="backup-link-row">
|
||||
<button className="backup-text-btn" onClick={cancelConnect}>
|
||||
Stop linking
|
||||
</button>
|
||||
<div className="backup-links">
|
||||
<button className="backup-text-btn" onClick={openAuthUrl}>
|
||||
Open link again
|
||||
</button>
|
||||
<span className="backup-link-sep">·</span>
|
||||
<button className="backup-text-btn" onClick={copyAuthUrl}>
|
||||
Copy link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !connected ? (
|
||||
<div className="backup-intro">
|
||||
<p>
|
||||
Connect Google Drive to keep a private backup of every book. margin only ever sees the files it
|
||||
creates in a <strong>margin</strong> folder, never the rest of your Drive.
|
||||
</p>
|
||||
<button className="btn-primary" disabled={working} onClick={connect}>
|
||||
{working ? "Connecting…" : "Connect Google Drive"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="backup-account">
|
||||
<div className="backup-account-info">
|
||||
<span className="field-label">Connected account</span>
|
||||
<span className="backup-email">{email || "Google Drive"}</span>
|
||||
</div>
|
||||
<button className="btn-ghost" disabled={working} onClick={disconnect}>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="backup-status-row">
|
||||
<span className={`backup-dot ${pending ? "is-pending" : "is-synced"}`} />
|
||||
<span>{pending ? "Changes not backed up" : formatBackupTime(lastBackup)}</span>
|
||||
</div>
|
||||
|
||||
<div className="backup-actions">
|
||||
<button className="btn-primary" disabled={working} onClick={() => backup()}>
|
||||
{working ? "Working…" : "Back up now"}
|
||||
</button>
|
||||
<button
|
||||
className="btn-ghost"
|
||||
disabled={working || remoteCount === 0}
|
||||
onClick={() => setConfirmRestore(true)}
|
||||
>
|
||||
{remoteCount === null ? "Restore from Drive" : `Restore ${remoteCount} book${remoteCount === 1 ? "" : "s"}`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="backup-note">
|
||||
Backs up automatically when you close margin, and every 15 minutes while there are changes.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{confirmRestore && (
|
||||
<ConfirmDialog
|
||||
title="Restore from Google Drive"
|
||||
message={
|
||||
<>
|
||||
This replaces your local books with the copies in Google Drive. Any local changes that haven't been backed
|
||||
up will be lost.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Restore"
|
||||
onConfirm={() => {
|
||||
setConfirmRestore(false);
|
||||
restore();
|
||||
}}
|
||||
onClose={() => setConfirmRestore(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -107,8 +107,8 @@ function PdfDock() {
|
||||
clearTimeout(timer.current);
|
||||
setBusy(true);
|
||||
timer.current = setTimeout(() => {
|
||||
const { source, images } = coverActive ? coverToPdfInputs(book) : chapterToPdfInputs(book, idx);
|
||||
compilePdf(source, images)
|
||||
const { source, images, fonts } = coverActive ? coverToPdfInputs(book) : chapterToPdfInputs(book, idx);
|
||||
compilePdf(source, images, false, fonts)
|
||||
.then((bytes) => {
|
||||
setPdf(bytes);
|
||||
setError(null);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Dock } from "./Dock";
|
||||
import { ResizeHandle } from "./ResizeHandle";
|
||||
import { Icon } from "./Icon";
|
||||
import { Settings } from "./Settings";
|
||||
import { BackupButton } from "./BackupButton";
|
||||
import { CoverView } from "./CoverView";
|
||||
import { FindBar } from "./FindBar";
|
||||
import { ProofPopover } from "./ProofPopover";
|
||||
@@ -12,6 +13,7 @@ import { Editor } from "../editor/Editor";
|
||||
import { FloatingToolbar } from "../editor/FloatingToolbar";
|
||||
import type { ProofCoords, ProofIssue, ProofingStorage } from "../editor/proofing";
|
||||
import { COVER_ID, useBook } from "../store/useBook";
|
||||
import { useBackup } from "../store/useBackup";
|
||||
import { useProofing } from "../store/useProofing";
|
||||
import { useTheme } from "../store/useTheme";
|
||||
import { useWidth } from "../store/useWidth";
|
||||
@@ -79,6 +81,7 @@ export function EditorView() {
|
||||
saveBook(current)
|
||||
.then(() => {
|
||||
if (useBook.getState().book === current) markSaved();
|
||||
useBackup.getState().refresh();
|
||||
})
|
||||
.catch((e) => setNotice(`Save failed: ${e}`));
|
||||
}, [markSaved, setNotice]);
|
||||
@@ -163,6 +166,7 @@ export function EditorView() {
|
||||
{dirty && <span className="dirty-dot" />}
|
||||
</button>
|
||||
<div className="actions">
|
||||
{isDesktop && <BackupButton />}
|
||||
<button className="icon-btn" data-on={findOpen} onClick={() => (findOpen ? setFindOpen(false) : openFind(false))} title="Find (⌘F)">
|
||||
<Icon d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" />
|
||||
</button>
|
||||
|
||||
@@ -7,13 +7,20 @@ import { isDesktop } from "../ipc";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
import { Icon } from "./Icon";
|
||||
import { RowMenu } from "./RowMenu";
|
||||
import { BackupButton } from "./BackupButton";
|
||||
import { useBackup } from "../store/useBackup";
|
||||
import { useBook } from "../store/useBook";
|
||||
|
||||
export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
||||
const [books, setBooks] = useState<BookSummary[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<BookSummary | null>(null);
|
||||
const notice = useBook((s) => s.notice);
|
||||
const setNotice = useBook((s) => s.setNotice);
|
||||
const restoreNonce = useBackup((s) => s.restoreNonce);
|
||||
const restoreWorking = useBackup((s) => s.phase === "working");
|
||||
const restoreFromDrive = useBackup((s) => s.restoreFromDrive);
|
||||
|
||||
const refresh = () =>
|
||||
listBooks()
|
||||
@@ -23,6 +30,14 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (restoreNonce) refresh();
|
||||
}, [restoreNonce]);
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
const timer = setTimeout(() => setNotice(null), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [notice]);
|
||||
|
||||
const handleExample = async () => {
|
||||
const copy = exampleBook();
|
||||
@@ -64,7 +79,9 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
||||
|
||||
return (
|
||||
<div className="library">
|
||||
<header className="library-head" data-tauri-drag-region />
|
||||
<header className="library-head" data-tauri-drag-region>
|
||||
{isDesktop && <BackupButton />}
|
||||
</header>
|
||||
<div className="shelf">
|
||||
<button className="card card-action" onClick={() => createAndOpenBook(onOpen, setNotice)}>
|
||||
<Icon d="M12 5v14M5 12h14" size={20} />
|
||||
@@ -100,6 +117,14 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
{loaded && books.length === 0 && isDesktop && (
|
||||
<div className="library-restore">
|
||||
<button className="restore-link" onClick={restoreFromDrive} disabled={restoreWorking}>
|
||||
<Icon d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5" size={15} />
|
||||
{restoreWorking ? "Restoring…" : "Restore from Google Drive"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{notice && (
|
||||
<div className="toast" onClick={() => setNotice(null)}>
|
||||
{notice}
|
||||
|
||||
+102
-3
@@ -1,6 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useBook } from "../store/useBook";
|
||||
import { listSystemFonts } from "../ipc";
|
||||
import {
|
||||
type BookFonts,
|
||||
type FontRef,
|
||||
BUNDLED_FONTS,
|
||||
DEFAULT_FONTS,
|
||||
FONT_PAIRINGS,
|
||||
decodeRef,
|
||||
encodeRef,
|
||||
fontStack,
|
||||
pairingFor,
|
||||
} from "../model/fonts";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
const LANGUAGES = [
|
||||
@@ -12,6 +24,8 @@ const LANGUAGES = [
|
||||
{ value: "pt", label: "Portuguese" },
|
||||
];
|
||||
|
||||
let systemFontCache: string[] | null = null;
|
||||
|
||||
interface Draft {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
@@ -19,6 +33,7 @@ interface Draft {
|
||||
isbn: string;
|
||||
language: string;
|
||||
bleed: boolean;
|
||||
fonts: BookFonts;
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
@@ -30,6 +45,30 @@ function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function FontSelect({ value, system, onChange }: { value: FontRef; system: string[]; onChange: (ref: FontRef) => void }) {
|
||||
const extra = value.kind === "system" && !system.includes(value.family) ? [value.family] : [];
|
||||
return (
|
||||
<select value={encodeRef(value)} style={{ fontFamily: fontStack(value) }} onChange={(e) => onChange(decodeRef(e.target.value))}>
|
||||
<optgroup label="Bundled">
|
||||
{BUNDLED_FONTS.map((f) => (
|
||||
<option key={f.id} value={`b:${f.id}`}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{(system.length > 0 || extra.length > 0) && (
|
||||
<optgroup label="System">
|
||||
{[...extra, ...system].map((name) => (
|
||||
<option key={name} value={`s:${name}`}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export function Settings({ onClose, onSave }: { onClose: () => void; onSave: () => void }) {
|
||||
const book = useBook((s) => s.book);
|
||||
const setMetadata = useBook((s) => s.setMetadata);
|
||||
@@ -42,16 +81,31 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
|
||||
isbn: book?.metadata.isbn ?? "",
|
||||
language: book?.metadata.language ?? "en",
|
||||
bleed: book?.settings.bleed ?? true,
|
||||
fonts: book?.settings.fonts ?? DEFAULT_FONTS,
|
||||
}));
|
||||
const [system, setSystem] = useState<string[]>(() => systemFontCache ?? []);
|
||||
const [advanced, setAdvanced] = useState(() => pairingFor(draft.fonts) === null);
|
||||
|
||||
useEffect(() => {
|
||||
if (systemFontCache) return;
|
||||
listSystemFonts().then((fonts) => {
|
||||
systemFontCache = fonts;
|
||||
setSystem(fonts);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!book) return null;
|
||||
|
||||
const set = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
|
||||
const setFont = (slot: "body" | "heading", ref: FontRef) => set({ fonts: { ...draft.fonts, [slot]: ref } });
|
||||
|
||||
const activePreset = pairingFor(draft.fonts);
|
||||
const usesSystem = draft.fonts.body.kind === "system" || draft.fonts.heading.kind === "system";
|
||||
|
||||
const save = () => {
|
||||
const { bleed, ...metadata } = draft;
|
||||
const { bleed, fonts, ...metadata } = draft;
|
||||
setMetadata(metadata);
|
||||
setSettings({ bleed });
|
||||
setSettings({ bleed, fonts });
|
||||
onSave();
|
||||
onClose();
|
||||
};
|
||||
@@ -87,6 +141,51 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<div className="field">
|
||||
<span className="field-label">Typography</span>
|
||||
<div className="font-presets">
|
||||
{FONT_PAIRINGS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className="font-preset"
|
||||
data-on={activePreset === p.id}
|
||||
onClick={() => set({ fonts: { body: p.body, heading: p.heading } })}
|
||||
>
|
||||
<span className="font-preset-demo" style={{ fontFamily: fontStack(p.heading) }}>
|
||||
Ag
|
||||
</span>
|
||||
<span className="font-preset-name">{p.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="font-advanced" onClick={() => setAdvanced((v) => !v)}>
|
||||
{advanced ? "Hide custom fonts" : "Customize fonts"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{advanced && (
|
||||
<>
|
||||
<Field label="Body font">
|
||||
<FontSelect value={draft.fonts.body} system={system} onChange={(ref) => setFont("body", ref)} />
|
||||
</Field>
|
||||
<Field label="Heading font">
|
||||
<FontSelect value={draft.fonts.heading} system={system} onChange={(ref) => setFont("heading", ref)} />
|
||||
</Field>
|
||||
{usesSystem && (
|
||||
<p className="font-note">System fonts preview here but may not embed in exported PDF or EPUB, so they can look different on other devices.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="font-sample" style={{ fontFamily: fontStack(draft.fonts.body) }}>
|
||||
<span className="font-sample-title" style={{ fontFamily: fontStack(draft.fonts.heading) }}>
|
||||
Chapter One
|
||||
</span>
|
||||
She read the first line twice, and the whole quiet house seemed to lean in to listen.
|
||||
</div>
|
||||
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={draft.bleed} onChange={(e) => set({ bleed: e.target.checked })} />
|
||||
Add bleed for full-page images (print)
|
||||
|
||||
+63
-24
@@ -1,6 +1,7 @@
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { type Book, type FigurePlacement, TRIM_DIMS, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book";
|
||||
import { fontStack, fontsUsed } from "../model/fonts";
|
||||
|
||||
export interface EpubFile {
|
||||
path: string;
|
||||
@@ -38,6 +39,56 @@ function mediaType(path: string): string {
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
interface FontAssets {
|
||||
files: EpubFile[];
|
||||
faces: string;
|
||||
manifest: string[];
|
||||
}
|
||||
|
||||
function base64FromBuffer(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
async function fetchFontBase64(file: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`/fonts/${file}`);
|
||||
if (!res.ok) return null;
|
||||
return base64FromBuffer(await res.arrayBuffer());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFontAssets(book: Book): Promise<FontAssets> {
|
||||
const used = fontsUsed(book.settings.fonts).bundled;
|
||||
const files: EpubFile[] = [];
|
||||
const faces: string[] = [];
|
||||
const manifest: string[] = [];
|
||||
let n = 0;
|
||||
for (const font of used) {
|
||||
const variants = [{ file: font.regular, style: "normal" }];
|
||||
if (font.italic) variants.push({ file: font.italic, style: "italic" });
|
||||
for (const variant of variants) {
|
||||
const data = await fetchFontBase64(variant.file);
|
||||
if (!data) continue;
|
||||
const href = `fonts/${variant.file}`;
|
||||
const id = `font-${++n}`;
|
||||
files.push({ path: `OEBPS/${href}`, data, encoding: "base64" });
|
||||
manifest.push(`<item id="${id}" href="${attr(href)}" media-type="font/ttf"/>`);
|
||||
faces.push(
|
||||
`@font-face {\n font-family: "${font.family}";\n font-style: ${variant.style};\n font-weight: ${font.weight};\n src: url("${href}");\n}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { files, faces: faces.join("\n\n"), manifest };
|
||||
}
|
||||
|
||||
function inline(node: JSONContent): string {
|
||||
if (node.type === "text") {
|
||||
let text = esc(node.text ?? "");
|
||||
@@ -240,6 +291,7 @@ function contentOpf(
|
||||
chapterIds: string[],
|
||||
assetPaths: string[],
|
||||
cover: { href: string; mediaType: string },
|
||||
fontManifest: string[],
|
||||
): string {
|
||||
const meta = book.metadata;
|
||||
const manifest: string[] = [
|
||||
@@ -247,8 +299,7 @@ function contentOpf(
|
||||
`<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>`,
|
||||
`<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>`,
|
||||
`<item id="css" href="style.css" media-type="text/css"/>`,
|
||||
`<item id="font-regular" href="fonts/Literata-VF.ttf" media-type="font/ttf"/>`,
|
||||
`<item id="font-italic" href="fonts/Literata-Italic-VF.ttf" media-type="font/ttf"/>`,
|
||||
...fontManifest,
|
||||
];
|
||||
chapterIds.forEach((_, i) => {
|
||||
manifest.push(
|
||||
@@ -334,23 +385,9 @@ function chapterXhtml(book: Book, index: number, paths: Map<string, string>): st
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function styleCss(): string {
|
||||
return `@font-face {
|
||||
font-family: "Literata";
|
||||
font-style: normal;
|
||||
font-weight: 300 700;
|
||||
src: url("fonts/Literata-VF.ttf");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Literata";
|
||||
font-style: italic;
|
||||
font-weight: 300 700;
|
||||
src: url("fonts/Literata-Italic-VF.ttf");
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: "Literata", Georgia, serif;
|
||||
function styleCss(book: Book, faces: string): string {
|
||||
return `${faces ? faces + "\n\n" : ""}html {
|
||||
font-family: ${fontStack(book.settings.fonts.body)};
|
||||
}
|
||||
|
||||
.cover {
|
||||
@@ -383,6 +420,7 @@ p[data-indent="true"] {
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: ${fontStack(book.settings.fonts.heading)};
|
||||
hyphens: none;
|
||||
line-height: 1.25;
|
||||
font-weight: 500;
|
||||
@@ -477,7 +515,7 @@ figure.placement-float-top {
|
||||
`;
|
||||
}
|
||||
|
||||
export function bookToEpub(book: Book): EpubFile[] {
|
||||
export function bookToEpub(book: Book, fontAssets: FontAssets): EpubFile[] {
|
||||
const { assets, paths } = extractFigures(book);
|
||||
const cover = buildCover(book);
|
||||
const chapterIds = book.chapters.map((c) => c.id);
|
||||
@@ -488,12 +526,12 @@ export function bookToEpub(book: Book): EpubFile[] {
|
||||
{ path: "META-INF/container.xml", data: containerXml(), encoding: "utf8" },
|
||||
{
|
||||
path: "OEBPS/content.opf",
|
||||
data: contentOpf(book, chapterIds, assetPaths, cover),
|
||||
data: contentOpf(book, chapterIds, assetPaths, cover, fontAssets.manifest),
|
||||
encoding: "utf8",
|
||||
},
|
||||
{ path: "OEBPS/cover.xhtml", data: coverXhtml(book, cover.href), encoding: "utf8" },
|
||||
{ path: "OEBPS/nav.xhtml", data: navXhtml(book), encoding: "utf8" },
|
||||
{ path: "OEBPS/style.css", data: styleCss(), encoding: "utf8" },
|
||||
{ path: "OEBPS/style.css", data: styleCss(book, fontAssets.faces), encoding: "utf8" },
|
||||
cover.asset,
|
||||
];
|
||||
|
||||
@@ -505,12 +543,13 @@ export function bookToEpub(book: Book): EpubFile[] {
|
||||
});
|
||||
});
|
||||
|
||||
files.push(...assets);
|
||||
files.push(...assets, ...fontAssets.files);
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function buildEpub(book: Book): Promise<Uint8Array> {
|
||||
const files = bookToEpub(book);
|
||||
const fontAssets = await loadFontAssets(book);
|
||||
const files = bookToEpub(book, fontAssets);
|
||||
const buf = await invoke<ArrayBuffer>("package_epub", { files });
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
@@ -8,8 +8,8 @@ const PDF = [{ name: "PDF", extensions: ["pdf"] }];
|
||||
const EPUB = [{ name: "EPUB", extensions: ["epub"] }];
|
||||
|
||||
export async function exportPdf(book: Book): Promise<void> {
|
||||
const { source, images } = bookToPdfInputs(book);
|
||||
const bytes = await compilePdf(source, images, true);
|
||||
const { source, images, fonts } = bookToPdfInputs(book);
|
||||
const bytes = await compilePdf(source, images, true, fonts);
|
||||
await saveBytes(bytes, `${book.metadata.title || "Untitled"}.pdf`, PDF);
|
||||
}
|
||||
|
||||
|
||||
+28
-13
@@ -1,7 +1,18 @@
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
import { type Book, type FigurePlacement, type TrimSize, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book";
|
||||
import { fontFamilyName, fontsUsed } from "../model/fonts";
|
||||
import type { ImageInput } from "../ipc";
|
||||
|
||||
export interface PdfFonts {
|
||||
bundled: string[];
|
||||
system: string[];
|
||||
}
|
||||
|
||||
function pdfFonts(book: Book): PdfFonts {
|
||||
const used = fontsUsed(book.settings.fonts);
|
||||
return { bundled: used.bundled.map((f) => f.id), system: used.system };
|
||||
}
|
||||
|
||||
const TRIM: Record<TrimSize, { w: string; h: string }> = {
|
||||
"6x9": { w: "6in", h: "9in" },
|
||||
"5.5x8.5": { w: "5.5in", h: "8.5in" },
|
||||
@@ -104,6 +115,8 @@ function preamble(book: Book): string {
|
||||
const trim = TRIM[book.settings.trim];
|
||||
const meta = book.metadata;
|
||||
const bleed = book.settings.bleed ? "0.125in" : "0in";
|
||||
const body = fontFamilyName(book.settings.fonts.body);
|
||||
const heading = fontFamilyName(book.settings.fonts.heading);
|
||||
return `#set document(title: ${str(meta.title || "Untitled")}, author: ${str(meta.author)})
|
||||
#set page(
|
||||
width: ${trim.w} + ${bleed},
|
||||
@@ -111,9 +124,9 @@ function preamble(book: Book): string {
|
||||
margin: (inside: 0.875in, outside: 0.625in + ${bleed}, top: 0.8in + ${bleed}, bottom: 0.8in + ${bleed}),
|
||||
binding: left,
|
||||
)
|
||||
#set text(font: "Literata", size: 11pt, lang: ${str(meta.language || "en")}, hyphenate: true)
|
||||
#set text(font: ${str(body)}, size: 11pt, lang: ${str(meta.language || "en")}, hyphenate: true)
|
||||
#set par(justify: true, leading: 0.72em, spacing: 0.72em)
|
||||
#show heading: set text(font: "Literata", weight: "medium")
|
||||
#show heading: set text(font: ${str(heading)}, weight: "medium")
|
||||
#show heading.where(level: 1): set text(size: 22pt)
|
||||
#show heading.where(level: 2): set block(above: 1.4em, below: 0.6em)
|
||||
#show heading.where(level: 3): set block(above: 1.2em, below: 0.5em)
|
||||
@@ -125,10 +138,10 @@ function preamble(book: Book): string {
|
||||
#let titlepage(title, subtitle, author) = {
|
||||
v(2.4in)
|
||||
align(center)[
|
||||
#text(font: "Literata", size: 30pt, weight: "medium")[#title]
|
||||
#text(font: ${str(heading)}, size: 30pt, weight: "medium")[#title]
|
||||
#if subtitle != "" {
|
||||
v(0.6em)
|
||||
text(font: "Literata", size: 15pt, style: "italic", fill: rgb("#6b6458"))[#subtitle]
|
||||
text(font: ${str(body)}, size: 15pt, style: "italic", fill: rgb("#6b6458"))[#subtitle]
|
||||
}
|
||||
#if author != "" {
|
||||
v(1.7em)
|
||||
@@ -164,7 +177,7 @@ function preamble(book: Book): string {
|
||||
set par(justify: false, first-line-indent: 0pt)
|
||||
block(below: 2em)[
|
||||
#show heading: set block(above: 0pt, below: 0pt)
|
||||
#heading(level: 1, numbering: none, outlined: false, bookmarked: true)[#text(font: "Literata", size: 24pt, weight: "medium")[Contents]]
|
||||
#heading(level: 1, numbering: none, outlined: false, bookmarked: true)[#text(font: ${str(heading)}, size: 24pt, weight: "medium")[Contents]]
|
||||
]
|
||||
context {
|
||||
let items = query(<chap>).filter(it => it.value.toc != false)
|
||||
@@ -200,13 +213,15 @@ function imageExtension(dataUrl: string): string {
|
||||
function coverBlock(book: Book, coverPath?: string): string {
|
||||
const c = book.cover;
|
||||
const meta = book.metadata;
|
||||
const body = fontFamilyName(book.settings.fonts.body);
|
||||
const heading = fontFamilyName(book.settings.fonts.heading);
|
||||
|
||||
if (c.kind === "image" && coverPath) {
|
||||
return `#page(margin: 0pt, numbering: none)[#image(${str(coverPath)}, width: 100%, height: 100%, fit: "cover")]`;
|
||||
}
|
||||
|
||||
const subtitle = meta.subtitle
|
||||
? `#v(0.55em)\n #text(font: "Literata", size: 14pt, style: "italic")[${esc(meta.subtitle)}]`
|
||||
? `#v(0.55em)\n #text(font: ${str(body)}, size: 14pt, style: "italic")[${esc(meta.subtitle)}]`
|
||||
: "";
|
||||
const author = meta.author
|
||||
? `#v(1fr)\n #text(font: "Hanken Grotesk", size: 11pt, weight: "semibold", tracking: 0.24em)[#upper[${esc(meta.author)}]]\n #v(0.55in)`
|
||||
@@ -216,7 +231,7 @@ function coverBlock(book: Book, coverPath?: string): string {
|
||||
#set align(center)
|
||||
#set text(fill: rgb(${str(c.ink)}))
|
||||
#v(1fr)
|
||||
#text(font: "Literata", size: 30pt, weight: "medium")[${esc(meta.title || "Untitled")}]
|
||||
#text(font: ${str(heading)}, size: 30pt, weight: "medium")[${esc(meta.title || "Untitled")}]
|
||||
#v(0.5em)
|
||||
#line(length: 16%, stroke: 0.6pt + rgb(${str(c.ink)}))
|
||||
${subtitle}
|
||||
@@ -334,16 +349,16 @@ export function bookToTypst(book: Book, paths: Map<string, string> = new Map(),
|
||||
return `${preamble(book)}\n\n${cover}\n\n${front}\n\n${body}\n`;
|
||||
}
|
||||
|
||||
export function bookToPdfInputs(book: Book): { source: string; images: ImageInput[] } {
|
||||
export function bookToPdfInputs(book: Book): { source: string; images: ImageInput[]; fonts: PdfFonts } {
|
||||
const { images, paths } = extractImages(book);
|
||||
const coverPath = pushCoverImage(book, images);
|
||||
return { source: bookToTypst(book, paths, coverPath), images };
|
||||
return { source: bookToTypst(book, paths, coverPath), images, fonts: pdfFonts(book) };
|
||||
}
|
||||
|
||||
export function coverToPdfInputs(book: Book): { source: string; images: ImageInput[] } {
|
||||
export function coverToPdfInputs(book: Book): { source: string; images: ImageInput[]; fonts: PdfFonts } {
|
||||
const images: ImageInput[] = [];
|
||||
const coverPath = pushCoverImage(book, images);
|
||||
return { source: `${preamble(book)}\n\n${coverBlock(book, coverPath)}\n`, images };
|
||||
return { source: `${preamble(book)}\n\n${coverBlock(book, coverPath)}\n`, images, fonts: pdfFonts(book) };
|
||||
}
|
||||
|
||||
function chapterToTypst(book: Book, index: number, paths: Map<string, string>): string {
|
||||
@@ -358,8 +373,8 @@ ${chapterBody(book.chapters[index].content, paths)}
|
||||
`;
|
||||
}
|
||||
|
||||
export function chapterToPdfInputs(book: Book, index: number): { source: string; images: ImageInput[] } {
|
||||
export function chapterToPdfInputs(book: Book, index: number): { source: string; images: ImageInput[]; fonts: PdfFonts } {
|
||||
const chapter = book.chapters[index];
|
||||
const { images, paths } = collectImages([chapter.content]);
|
||||
return { source: chapterToTypst(book, index, paths), images };
|
||||
return { source: chapterToTypst(book, index, paths), images, fonts: pdfFonts(book) };
|
||||
}
|
||||
+2
-1
@@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
import { type Book, type BookMetadata, type Chapter, type Cover, createCover } from "../model/book";
|
||||
import { DEFAULT_FONTS } from "../model/fonts";
|
||||
|
||||
export interface RawFile {
|
||||
path: string;
|
||||
@@ -515,7 +516,7 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
|
||||
id: crypto.randomUUID(),
|
||||
metadata,
|
||||
theme: "quiet-press",
|
||||
settings: { trim: "6x9", bleed: true },
|
||||
settings: { trim: "6x9", bleed: true, fonts: DEFAULT_FONTS },
|
||||
cover: findCover(opf, manifest, byPath),
|
||||
chapters,
|
||||
};
|
||||
|
||||
+22
-1
@@ -7,11 +7,32 @@ export interface ImageInput {
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface PdfFonts {
|
||||
bundled: string[];
|
||||
system: string[];
|
||||
}
|
||||
|
||||
export async function compilePdf(
|
||||
source: string,
|
||||
images: ImageInput[] = [],
|
||||
emitWarnings = false,
|
||||
fonts: PdfFonts = { bundled: [], system: [] },
|
||||
): Promise<Uint8Array> {
|
||||
const buffer = await invoke<ArrayBuffer>("compile_pdf", { source, images, emitWarnings });
|
||||
const buffer = await invoke<ArrayBuffer>("compile_pdf", {
|
||||
source,
|
||||
images,
|
||||
emitWarnings,
|
||||
bundledFonts: fonts.bundled,
|
||||
systemFonts: fonts.system,
|
||||
});
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
export async function listSystemFonts(): Promise<string[]> {
|
||||
if (!isDesktop) return [];
|
||||
try {
|
||||
return await invoke<string[]>("list_system_fonts");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -1,4 +1,5 @@
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
import { type BookFonts, DEFAULT_FONTS } from "./fonts";
|
||||
|
||||
export type TrimSize = "6x9" | "5.5x8.5" | "5x8" | "a5";
|
||||
export type FigurePlacement = "inline" | "full-width" | "full-page" | "float-top";
|
||||
@@ -24,6 +25,7 @@ export interface BookMetadata {
|
||||
export interface BookSettings {
|
||||
trim: TrimSize;
|
||||
bleed: boolean;
|
||||
fonts: BookFonts;
|
||||
}
|
||||
|
||||
export type CoverKind = "default" | "image";
|
||||
@@ -149,14 +151,14 @@ export function createCover(): Cover {
|
||||
}
|
||||
|
||||
const DEFAULT_METADATA: BookMetadata = { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" };
|
||||
const DEFAULT_SETTINGS: BookSettings = { trim: "6x9", bleed: true };
|
||||
const DEFAULT_SETTINGS: BookSettings = { trim: "6x9", bleed: true, fonts: DEFAULT_FONTS };
|
||||
|
||||
export function normalizeBook(book: Book): Book {
|
||||
const chapters = (Array.isArray(book.chapters) ? book.chapters : []).filter(Boolean);
|
||||
return {
|
||||
...book,
|
||||
metadata: { ...DEFAULT_METADATA, ...book.metadata },
|
||||
settings: { ...DEFAULT_SETTINGS, ...book.settings },
|
||||
settings: { ...DEFAULT_SETTINGS, ...book.settings, fonts: { ...DEFAULT_FONTS, ...book.settings?.fonts } },
|
||||
cover: book.cover ? { ...createCover(), ...book.cover } : createCover(),
|
||||
chapters: (chapters.length ? chapters : [createChapter()]).map((c) =>
|
||||
c.updatedAt ? c : { ...c, updatedAt: Date.now() },
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
export type FontCategory = "serif" | "sans" | "display";
|
||||
|
||||
export interface BundledFont {
|
||||
id: string;
|
||||
label: string;
|
||||
family: string;
|
||||
category: FontCategory;
|
||||
regular: string;
|
||||
italic?: string;
|
||||
weight: string;
|
||||
}
|
||||
|
||||
export const BUNDLED_FONTS: BundledFont[] = [
|
||||
{ id: "literata", label: "Literata", family: "Literata", category: "serif", regular: "Literata-VF.ttf", italic: "Literata-Italic-VF.ttf", weight: "200 900" },
|
||||
{ id: "eb-garamond", label: "EB Garamond", family: "EB Garamond", category: "serif", regular: "EBGaramond-VF.ttf", italic: "EBGaramond-Italic-VF.ttf", weight: "400 800" },
|
||||
{ id: "lora", label: "Lora", family: "Lora", category: "serif", regular: "Lora-VF.ttf", italic: "Lora-Italic-VF.ttf", weight: "400 700" },
|
||||
{ id: "source-serif", label: "Source Serif 4", family: "Source Serif 4", category: "serif", regular: "SourceSerif4-VF.ttf", italic: "SourceSerif4-Italic-VF.ttf", weight: "200 900" },
|
||||
{ id: "fraunces", label: "Fraunces", family: "Fraunces", category: "display", regular: "Fraunces-VF.ttf", italic: "Fraunces-Italic-VF.ttf", weight: "100 900" },
|
||||
{ id: "hanken", label: "Hanken Grotesk", family: "Hanken Grotesk", category: "sans", regular: "HankenGrotesk-VF.ttf", italic: "HankenGrotesk-Italic-VF.ttf", weight: "100 900" },
|
||||
];
|
||||
|
||||
export type FontRef =
|
||||
| { kind: "bundled"; id: string }
|
||||
| { kind: "system"; family: string };
|
||||
|
||||
export interface BookFonts {
|
||||
body: FontRef;
|
||||
heading: FontRef;
|
||||
}
|
||||
|
||||
export interface FontPairing {
|
||||
id: string;
|
||||
label: string;
|
||||
body: FontRef;
|
||||
heading: FontRef;
|
||||
}
|
||||
|
||||
const bundled = (id: string): FontRef => ({ kind: "bundled", id });
|
||||
|
||||
export const FONT_PAIRINGS: FontPairing[] = [
|
||||
{ id: "quiet-press", label: "Quiet Press", body: bundled("literata"), heading: bundled("literata") },
|
||||
{ id: "classic", label: "Classic", body: bundled("eb-garamond"), heading: bundled("eb-garamond") },
|
||||
{ id: "editorial", label: "Editorial", body: bundled("source-serif"), heading: bundled("fraunces") },
|
||||
{ id: "modern", label: "Modern", body: bundled("lora"), heading: bundled("hanken") },
|
||||
{ id: "contrast", label: "Contrast", body: bundled("literata"), heading: bundled("hanken") },
|
||||
];
|
||||
|
||||
export const DEFAULT_FONTS: BookFonts = { body: bundled("literata"), heading: bundled("literata") };
|
||||
|
||||
const SERIF_FALLBACK = `Georgia, "Times New Roman", serif`;
|
||||
const SANS_FALLBACK = `ui-sans-serif, system-ui, -apple-system, sans-serif`;
|
||||
|
||||
export function bundledFont(id: string): BundledFont | undefined {
|
||||
return BUNDLED_FONTS.find((f) => f.id === id);
|
||||
}
|
||||
|
||||
export function encodeRef(ref: FontRef): string {
|
||||
return ref.kind === "bundled" ? `b:${ref.id}` : `s:${ref.family}`;
|
||||
}
|
||||
|
||||
export function decodeRef(value: string): FontRef {
|
||||
return value.startsWith("s:") ? { kind: "system", family: value.slice(2) } : { kind: "bundled", id: value.slice(2) };
|
||||
}
|
||||
|
||||
export function fontFamilyName(ref: FontRef): string {
|
||||
if (ref.kind === "system") return ref.family;
|
||||
return bundledFont(ref.id)?.family ?? "Literata";
|
||||
}
|
||||
|
||||
export function fontLabel(ref: FontRef): string {
|
||||
if (ref.kind === "system") return ref.family;
|
||||
return bundledFont(ref.id)?.label ?? ref.id;
|
||||
}
|
||||
|
||||
export function fontStack(ref: FontRef): string {
|
||||
if (ref.kind === "bundled") {
|
||||
const font = bundledFont(ref.id);
|
||||
if (!font) return SERIF_FALLBACK;
|
||||
return `"${font.family}", ${font.category === "sans" ? SANS_FALLBACK : SERIF_FALLBACK}`;
|
||||
}
|
||||
return `"${ref.family}", ${SERIF_FALLBACK}`;
|
||||
}
|
||||
|
||||
export function refsEqual(a: FontRef, b: FontRef): boolean {
|
||||
if (a.kind !== b.kind) return false;
|
||||
return a.kind === "bundled" ? a.id === (b as { id: string }).id : a.family === (b as { family: string }).family;
|
||||
}
|
||||
|
||||
export function pairingFor(fonts: BookFonts): string | null {
|
||||
const match = FONT_PAIRINGS.find((p) => refsEqual(p.body, fonts.body) && refsEqual(p.heading, fonts.heading));
|
||||
return match?.id ?? null;
|
||||
}
|
||||
|
||||
export function fontsUsed(fonts: BookFonts): { bundled: BundledFont[]; system: string[] } {
|
||||
const usedBundled = new Map<string, BundledFont>();
|
||||
const usedSystem = new Set<string>();
|
||||
for (const ref of [fonts.body, fonts.heading]) {
|
||||
if (ref.kind === "bundled") {
|
||||
const font = bundledFont(ref.id);
|
||||
if (font) usedBundled.set(font.id, font);
|
||||
} else {
|
||||
usedSystem.add(ref.family);
|
||||
}
|
||||
}
|
||||
return { bundled: [...usedBundled.values()], system: [...usedSystem] };
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { create } from "zustand";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import {
|
||||
type BackupStatus,
|
||||
gdriveBackup,
|
||||
gdriveConnect,
|
||||
gdriveDisconnect,
|
||||
gdriveRestore,
|
||||
gdriveStatus,
|
||||
} from "../backup";
|
||||
import { isDesktop } from "../ipc";
|
||||
import { useBook } from "./useBook";
|
||||
|
||||
type Phase = "idle" | "connecting" | "working" | "error";
|
||||
|
||||
interface BackupState {
|
||||
connected: boolean;
|
||||
email: string | null;
|
||||
lastBackup: number | null;
|
||||
pending: boolean;
|
||||
phase: Phase;
|
||||
error: string | null;
|
||||
authUrl: string | null;
|
||||
settingsOpen: boolean;
|
||||
restoreNonce: number;
|
||||
resolveConnect: ((ok: boolean) => void) | null;
|
||||
apply: (status: BackupStatus) => void;
|
||||
refresh: () => Promise<void>;
|
||||
connect: () => Promise<boolean>;
|
||||
cancelConnect: () => void;
|
||||
handleAuthEvent: (ok: boolean, error: string | null) => Promise<void>;
|
||||
openAuthUrl: () => void;
|
||||
copyAuthUrl: () => Promise<void>;
|
||||
disconnect: () => Promise<void>;
|
||||
backup: (silent?: boolean) => Promise<void>;
|
||||
restore: () => Promise<void>;
|
||||
restoreFromDrive: () => Promise<void>;
|
||||
openSettings: () => void;
|
||||
closeSettings: () => void;
|
||||
}
|
||||
|
||||
function notify(message: string) {
|
||||
useBook.getState().setNotice(message);
|
||||
}
|
||||
|
||||
export const useBackup = create<BackupState>((set, get) => ({
|
||||
connected: false,
|
||||
email: null,
|
||||
lastBackup: null,
|
||||
pending: false,
|
||||
phase: "idle",
|
||||
error: null,
|
||||
authUrl: null,
|
||||
settingsOpen: false,
|
||||
restoreNonce: 0,
|
||||
resolveConnect: null,
|
||||
apply: (status) =>
|
||||
set({
|
||||
connected: status.connected,
|
||||
email: status.email,
|
||||
lastBackup: status.lastBackup,
|
||||
pending: status.pending,
|
||||
}),
|
||||
refresh: async () => {
|
||||
if (!isDesktop) return;
|
||||
try {
|
||||
get().apply(await gdriveStatus());
|
||||
} catch {}
|
||||
},
|
||||
connect: () =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
get().resolveConnect?.(false);
|
||||
set({ phase: "connecting", error: null, authUrl: null, resolveConnect: resolve });
|
||||
gdriveConnect()
|
||||
.then((url) => set({ authUrl: url }))
|
||||
.catch((e) => {
|
||||
set({ phase: "error", error: String(e), resolveConnect: null });
|
||||
notify(`Could not connect: ${e}`);
|
||||
resolve(false);
|
||||
});
|
||||
}),
|
||||
cancelConnect: () => {
|
||||
const resolve = get().resolveConnect;
|
||||
set({ phase: "idle", authUrl: null, resolveConnect: null });
|
||||
resolve?.(false);
|
||||
},
|
||||
handleAuthEvent: async (ok, error) => {
|
||||
if (get().phase !== "connecting") return;
|
||||
const resolve = get().resolveConnect;
|
||||
if (ok) {
|
||||
await get().refresh();
|
||||
set({ phase: "idle", authUrl: null, error: null, resolveConnect: null });
|
||||
notify("Connected to Google Drive");
|
||||
} else {
|
||||
set({ phase: "error", authUrl: null, error: error ?? "authorization failed", resolveConnect: null });
|
||||
notify(`Could not connect: ${error ?? "authorization failed"}`);
|
||||
}
|
||||
resolve?.(ok);
|
||||
},
|
||||
openAuthUrl: () => {
|
||||
const url = get().authUrl;
|
||||
if (url) openUrl(url).catch(() => {});
|
||||
},
|
||||
copyAuthUrl: async () => {
|
||||
const url = get().authUrl;
|
||||
if (!url) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
notify("Authorization link copied");
|
||||
} catch {
|
||||
notify("Could not copy the link");
|
||||
}
|
||||
},
|
||||
disconnect: async () => {
|
||||
set({ phase: "working", error: null });
|
||||
try {
|
||||
get().apply(await gdriveDisconnect());
|
||||
set({ phase: "idle" });
|
||||
notify("Disconnected from Google Drive");
|
||||
} catch (e) {
|
||||
set({ phase: "error", error: String(e) });
|
||||
notify(`Could not disconnect: ${e}`);
|
||||
}
|
||||
},
|
||||
backup: async (silent = false) => {
|
||||
if (!get().connected || get().phase === "working") return;
|
||||
set({ phase: "working", error: null });
|
||||
try {
|
||||
const outcome = await gdriveBackup();
|
||||
get().apply(outcome);
|
||||
set({ phase: "idle" });
|
||||
if (!silent) notify(outcome.uploaded > 0 ? "Backed up to Google Drive" : "Nothing new to back up");
|
||||
} catch (e) {
|
||||
set({ phase: "error", error: String(e) });
|
||||
if (!silent) notify(`Backup failed: ${e}`);
|
||||
}
|
||||
},
|
||||
restore: async () => {
|
||||
set({ phase: "working", error: null });
|
||||
try {
|
||||
const result = await gdriveRestore();
|
||||
await get().refresh();
|
||||
set((s) => ({ phase: "idle", restoreNonce: s.restoreNonce + 1 }));
|
||||
notify(result.restored > 0 ? `Restored ${result.restored} file${result.restored === 1 ? "" : "s"}` : "No backups found in Google Drive");
|
||||
} catch (e) {
|
||||
set({ phase: "error", error: String(e) });
|
||||
notify(`Restore failed: ${e}`);
|
||||
}
|
||||
},
|
||||
restoreFromDrive: async () => {
|
||||
const ok = get().connected || (await get().connect());
|
||||
if (ok) await get().restore();
|
||||
},
|
||||
openSettings: () => set({ settingsOpen: true }),
|
||||
closeSettings: () => set({ settingsOpen: false }),
|
||||
}));
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "../model/book";
|
||||
import { loadActiveChapter, saveActiveChapter } from "../editor/positions";
|
||||
import { saveBook } from "../library";
|
||||
import { applyBookFonts, resetBookFonts } from "../book-style";
|
||||
|
||||
export const COVER_ID = "__cover__";
|
||||
|
||||
@@ -60,10 +61,12 @@ export const useBook = create<BookState>((set, get) => ({
|
||||
const saved = loadActiveChapter(normalized.id);
|
||||
const valid = saved === COVER_ID || normalized.chapters.some((c) => c.id === saved);
|
||||
const activeChapterId = valid ? (saved as string) : normalized.chapters[0]?.id ?? "";
|
||||
applyBookFonts(normalized.settings.fonts);
|
||||
set({ book: normalized, activeChapterId, dirty: false });
|
||||
},
|
||||
closeBook: () => {
|
||||
flushOutgoing(get().book, get().dirty);
|
||||
resetBookFonts();
|
||||
set({ book: null, activeChapterId: "", dirty: false });
|
||||
},
|
||||
setActiveChapter: (id) => set({ activeChapterId: id }),
|
||||
@@ -140,7 +143,12 @@ export const useBook = create<BookState>((set, get) => ({
|
||||
setMetadata: (patch) =>
|
||||
set((s) => (s.book ? { dirty: true, book: { ...s.book, metadata: { ...s.book.metadata, ...patch } } } : {})),
|
||||
setSettings: (patch) =>
|
||||
set((s) => (s.book ? { dirty: true, book: { ...s.book, settings: { ...s.book.settings, ...patch } } } : {})),
|
||||
set((s) => {
|
||||
if (!s.book) return {};
|
||||
const book = { ...s.book, settings: { ...s.book.settings, ...patch } };
|
||||
if (patch.fonts) applyBookFonts(book.settings.fonts);
|
||||
return { dirty: true, book };
|
||||
}),
|
||||
setCover: (patch) =>
|
||||
set((s) => (s.book ? { dirty: true, book: { ...s.book, cover: { ...s.book.cover, ...patch } } } : {})),
|
||||
markSaved: () => set({ dirty: false }),
|
||||
|
||||
+266
-5
@@ -427,6 +427,7 @@ body.resizing {
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: 31px;
|
||||
line-height: 1.16;
|
||||
@@ -434,6 +435,10 @@ body.resizing {
|
||||
margin: 15px 0 0;
|
||||
}
|
||||
|
||||
.prose :is(h1, h2, h3) {
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.prose p {
|
||||
font-size: 19px;
|
||||
line-height: 1.68;
|
||||
@@ -565,11 +570,15 @@ body.resizing {
|
||||
}
|
||||
|
||||
.page .p-title {
|
||||
font-family: var(--font-book);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 12px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.page :is(h2, h3) {
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.page p {
|
||||
font-family: var(--font-book);
|
||||
font-size: 6.6px;
|
||||
@@ -620,7 +629,7 @@ body.resizing {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
font-family: var(--font-book);
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: 31px;
|
||||
line-height: 1.16;
|
||||
@@ -660,6 +669,87 @@ body.resizing {
|
||||
accent-color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.font-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.font-preset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 9px 12px;
|
||||
min-width: 70px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--raised);
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms var(--ease), box-shadow 120ms var(--ease);
|
||||
}
|
||||
|
||||
.font-preset:hover {
|
||||
border-color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.font-preset[data-on="true"] {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.font-preset-demo {
|
||||
font-size: 23px;
|
||||
line-height: 1;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.font-preset-name {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-1);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.font-advanced {
|
||||
align-self: flex-start;
|
||||
margin-top: 4px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-2);
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.font-note {
|
||||
margin: 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-1);
|
||||
line-height: 1.5;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.font-sample {
|
||||
padding: 15px 17px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.font-sample-title {
|
||||
display: block;
|
||||
font-size: 21px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.editor-host,
|
||||
.ProseMirror {
|
||||
outline: none;
|
||||
@@ -1167,6 +1257,10 @@ body.resizing {
|
||||
|
||||
.library-head {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.shelf {
|
||||
@@ -1432,7 +1526,7 @@ body.resizing {
|
||||
}
|
||||
|
||||
.cover-title {
|
||||
font-family: var(--font-book);
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: 9.5cqw;
|
||||
line-height: 1.08;
|
||||
@@ -2108,7 +2202,7 @@ body.resizing {
|
||||
}
|
||||
|
||||
.device-title {
|
||||
font-family: var(--font-book);
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: calc(24 * var(--dv-scale));
|
||||
line-height: 1.2;
|
||||
@@ -2138,7 +2232,7 @@ body.resizing {
|
||||
.device-body h1,
|
||||
.device-body h2,
|
||||
.device-body h3 {
|
||||
font-family: var(--font-book);
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin: 1.2em 0 0.5em;
|
||||
@@ -2193,3 +2287,170 @@ body.resizing {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Google Drive backup */
|
||||
.backup-btn[data-state="pending"] {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.backup-btn[data-state="error"] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.backup-btn[data-state="working"] svg {
|
||||
animation: backup-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes backup-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.library-restore {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0 44px 60px;
|
||||
}
|
||||
|
||||
.restore-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--ink-faint);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-2);
|
||||
transition: color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.restore-link:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.restore-link:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.backup-intro,
|
||||
.backup-connecting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.backup-intro p,
|
||||
.backup-connecting p {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-3);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.backup-text-btn {
|
||||
color: var(--ink-faint);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-2);
|
||||
transition: color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.backup-text-btn:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.backup-waiting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-3);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.backup-waiting svg {
|
||||
color: var(--ink-soft);
|
||||
animation: backup-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.backup-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.backup-link-sep {
|
||||
color: var(--line-strong);
|
||||
}
|
||||
|
||||
.backup-link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.backup-account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.backup-account-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.backup-email {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-3);
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.backup-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 18px 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-3);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.backup-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.backup-dot.is-synced {
|
||||
background: var(--ink-faint);
|
||||
}
|
||||
|
||||
.backup-dot.is-pending {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.backup-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.backup-note {
|
||||
margin: 18px 0 0;
|
||||
color: var(--ink-faint);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -29,3 +29,67 @@
|
||||
font-style: italic;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "EB Garamond";
|
||||
src: url("/fonts/EBGaramond-VF.ttf") format("truetype-variations");
|
||||
font-weight: 400 800;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "EB Garamond";
|
||||
src: url("/fonts/EBGaramond-Italic-VF.ttf") format("truetype-variations");
|
||||
font-weight: 400 800;
|
||||
font-style: italic;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Lora";
|
||||
src: url("/fonts/Lora-VF.ttf") format("truetype-variations");
|
||||
font-weight: 400 700;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Lora";
|
||||
src: url("/fonts/Lora-Italic-VF.ttf") format("truetype-variations");
|
||||
font-weight: 400 700;
|
||||
font-style: italic;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Source Serif 4";
|
||||
src: url("/fonts/SourceSerif4-VF.ttf") format("truetype-variations");
|
||||
font-weight: 200 900;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Source Serif 4";
|
||||
src: url("/fonts/SourceSerif4-Italic-VF.ttf") format("truetype-variations");
|
||||
font-weight: 200 900;
|
||||
font-style: italic;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Fraunces";
|
||||
src: url("/fonts/Fraunces-VF.ttf") format("truetype-variations");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Fraunces";
|
||||
src: url("/fonts/Fraunces-Italic-VF.ttf") format("truetype-variations");
|
||||
font-weight: 100 900;
|
||||
font-style: italic;
|
||||
font-display: block;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
:root {
|
||||
--font-ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-book: "Literata", Georgia, "Times New Roman", serif;
|
||||
--font-heading: "Literata", Georgia, "Times New Roman", serif;
|
||||
|
||||
--r-sm: 5px;
|
||||
--r-md: 8px;
|
||||
|
||||
@@ -17,16 +17,16 @@ const cols = [
|
||||
{
|
||||
title: "Product",
|
||||
links: [
|
||||
{ label: "Features", href: "#features" },
|
||||
{ label: "Publishing", href: "#publish" },
|
||||
{ label: "Compare", href: "#compare" },
|
||||
{ label: "Pricing", href: "#pricing" },
|
||||
{ label: "Features", href: "/#features" },
|
||||
{ label: "Publishing", href: "/#publish" },
|
||||
{ label: "Compare", href: "/#compare" },
|
||||
{ label: "Pricing", href: "/#pricing" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Help",
|
||||
links: [
|
||||
{ label: "Questions", href: "#faq" },
|
||||
{ label: "Questions", href: "/#faq" },
|
||||
{ label: "Email us", href: `mailto:${site.supportEmail}` },
|
||||
],
|
||||
},
|
||||
@@ -50,14 +50,14 @@ const cols = [
|
||||
<p class="footer__title">{col.title}</p>
|
||||
<ul>
|
||||
{col.links.map((l) => {
|
||||
const isAnchor = l.href.startsWith("#") || l.href.startsWith("mailto:");
|
||||
const isExternal = l.href.startsWith("http");
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={l.href}
|
||||
data-download-os={(l as { os?: string }).os}
|
||||
target={isAnchor ? undefined : "_blank"}
|
||||
rel={isAnchor ? undefined : "noopener"}
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={isExternal ? "noopener" : undefined}
|
||||
>
|
||||
{l.label}
|
||||
</a>
|
||||
@@ -73,6 +73,10 @@ const cols = [
|
||||
|
||||
<div class="container footer__base">
|
||||
<span>© {site.name}. Built with <span class="heart">♥</span> in NYC</span>
|
||||
<nav class="footer__legal" aria-label="Legal">
|
||||
<a href="/privacy">Privacy</a>
|
||||
<a href="/terms">Terms</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -136,10 +140,23 @@ const cols = [
|
||||
.footer__base {
|
||||
padding-top: 22px;
|
||||
border-top: 1px solid var(--line);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.footer__legal {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
}
|
||||
.footer__legal a {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.footer__legal a:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
.heart {
|
||||
color: var(--danger);
|
||||
}
|
||||
@@ -153,5 +170,10 @@ const cols = [
|
||||
.footer__cols {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.footer__base {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -11,15 +11,15 @@ import Logo from "./Logo.astro";
|
||||
</a>
|
||||
|
||||
<nav class="nav__links" aria-label="Primary">
|
||||
<a href="#features">Features</a>
|
||||
<a href="#publish">Publishing</a>
|
||||
<a href="#compare">Compare</a>
|
||||
<a href="#pricing">Pricing</a>
|
||||
<a href="#faq">Questions</a>
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/#publish">Publishing</a>
|
||||
<a href="/#compare">Compare</a>
|
||||
<a href="/#pricing">Pricing</a>
|
||||
<a href="/#faq">Questions</a>
|
||||
</nav>
|
||||
|
||||
<div class="nav__actions">
|
||||
<a class="btn nav__cta" href="#download">
|
||||
<a class="btn nav__cta" href="/#download">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||
<path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
import Base from "./Base.astro";
|
||||
import Nav from "../components/Nav.astro";
|
||||
import Footer from "../components/Footer.astro";
|
||||
import { site } from "../data/site";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
updated: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const { title, updated, description } = Astro.props;
|
||||
---
|
||||
|
||||
<Base title={`${title} · ${site.name}`} description={description}>
|
||||
<Nav />
|
||||
<main class="legal">
|
||||
<div class="container legal__inner">
|
||||
<header class="legal__head">
|
||||
<p class="eyebrow">Legal</p>
|
||||
<h1>{title}</h1>
|
||||
<p class="legal__updated">Last updated {updated}</p>
|
||||
</header>
|
||||
<div class="legal__prose">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.legal {
|
||||
padding-block: clamp(48px, 7vw, 96px);
|
||||
}
|
||||
.legal__inner {
|
||||
max-width: 760px;
|
||||
}
|
||||
.legal__head {
|
||||
padding-bottom: clamp(28px, 4vw, 44px);
|
||||
margin-bottom: clamp(28px, 4vw, 44px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.legal__head h1 {
|
||||
font-family: var(--font-book);
|
||||
font-weight: 560;
|
||||
letter-spacing: -0.012em;
|
||||
line-height: 1.08;
|
||||
font-size: clamp(2.1rem, 1.4rem + 2.8vw, 3.2rem);
|
||||
}
|
||||
.legal__updated {
|
||||
margin-top: 16px;
|
||||
color: var(--ink-faint);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.legal__prose {
|
||||
color: var(--ink-soft);
|
||||
font-size: 1.04rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.legal__prose :global(h2) {
|
||||
font-size: clamp(1.3rem, 1.05rem + 0.9vw, 1.6rem);
|
||||
color: var(--ink);
|
||||
margin: 44px 0 14px;
|
||||
}
|
||||
.legal__prose :global(h2:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
.legal__prose :global(p) {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.legal__prose :global(ul) {
|
||||
margin: 0 0 16px;
|
||||
padding-left: 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.legal__prose :global(li) {
|
||||
padding-left: 4px;
|
||||
}
|
||||
.legal__prose :global(li::marker) {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.legal__prose :global(strong) {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.legal__prose :global(a) {
|
||||
color: var(--ink);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: var(--line-strong);
|
||||
}
|
||||
.legal__prose :global(a:hover) {
|
||||
text-decoration-color: var(--ink);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
import Legal from "../layouts/Legal.astro";
|
||||
import { site } from "../data/site";
|
||||
---
|
||||
|
||||
<Legal
|
||||
title="Privacy Policy"
|
||||
updated="June 22, 2026"
|
||||
description={`How ${site.name} handles your data. The short version: your book never leaves your machine, and we keep no copy.`}
|
||||
>
|
||||
<h2>The short version</h2>
|
||||
<p>
|
||||
Margin is an offline writing studio that runs on your own computer. Your book and everything
|
||||
you write stay on your device. We never receive a copy, and nothing you write is sent away to
|
||||
be read or stored by us. This page explains the few places where data is involved, so you know
|
||||
exactly what happens and what does not.
|
||||
</p>
|
||||
|
||||
<h2>What stays on your device</h2>
|
||||
<p>
|
||||
Your manuscript, your spelling and grammar checks, your fonts and book setup, and every print
|
||||
and ebook export all happen locally on your machine. None of this requires an account, a login,
|
||||
or an internet connection, and none of it passes through our servers.
|
||||
</p>
|
||||
|
||||
<h2>No account, no tracking</h2>
|
||||
<p>
|
||||
You do not need to sign up, log in, or give us an email address to write with Margin. This
|
||||
website does not use tracking cookies, advertising pixels, or third-party analytics. The only
|
||||
thing the site stores in your browser is your light or dark theme preference, kept locally so
|
||||
the page looks the way you left it.
|
||||
</p>
|
||||
|
||||
<h2>Downloads and updates</h2>
|
||||
<p>
|
||||
Margin is distributed through GitHub Releases. When you download the app, or when the app checks
|
||||
for a new version to keep itself up to date, a request is made to GitHub to fetch the latest
|
||||
release. As with any web request, GitHub can see your IP address for that request, handled under
|
||||
GitHub's own privacy policy. We do not add any tracking of our own to these requests.
|
||||
</p>
|
||||
|
||||
<h2>Optional backup to your Google Drive</h2>
|
||||
<p>
|
||||
Margin can save a backup of your book to your own Google Drive, but only if you turn it on. When
|
||||
you do, you connect your own Google account, and the backup is written to your Drive, in your
|
||||
account, under your control. We never receive a copy of that backup and cannot read it. You can
|
||||
disconnect Margin from your Google account at any time from your Google account settings, and the
|
||||
app works exactly the same with the feature switched off.
|
||||
</p>
|
||||
|
||||
<h2>Payments</h2>
|
||||
<p>
|
||||
If you buy Margin, the payment is handled by a third-party payment processor. We do not see or
|
||||
store your full card details. The processor handles the transaction and shares with us only what
|
||||
we need to confirm your purchase and provide support.
|
||||
</p>
|
||||
|
||||
<h2>Children</h2>
|
||||
<p>
|
||||
Margin is a tool for writers and is not directed at children under 13. We do not knowingly
|
||||
collect personal information from children.
|
||||
</p>
|
||||
|
||||
<h2>Changes to this policy</h2>
|
||||
<p>
|
||||
If we update this policy, we will change the date at the top of this page. Because the app keeps
|
||||
your book on your own device, the spirit of this policy is unlikely to change: your words belong
|
||||
to you.
|
||||
</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
Questions about your privacy? Write to <a href={`mailto:${site.supportEmail}`}>{site.supportEmail}</a>
|
||||
and a real person will answer.
|
||||
</p>
|
||||
</Legal>
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
import Legal from "../layouts/Legal.astro";
|
||||
import { site } from "../data/site";
|
||||
---
|
||||
|
||||
<Legal
|
||||
title="Terms of Use"
|
||||
updated="June 22, 2026"
|
||||
description={`The terms for using ${site.name}: a one-time purchase, your content stays yours, used as is.`}
|
||||
>
|
||||
<h2>Acceptance</h2>
|
||||
<p>
|
||||
These terms cover your use of Margin, the offline writing app, and this website. By downloading,
|
||||
buying, or using Margin, you agree to them. If you do not agree, please do not use the app.
|
||||
</p>
|
||||
|
||||
<h2>Your license to use Margin</h2>
|
||||
<p>
|
||||
Margin is a one-time purchase. When you buy it, you get a personal, non-exclusive license to
|
||||
install and use the app on the devices you own. You keep using the version you bought, and
|
||||
updates are included at no extra cost. You may not resell, rent, or redistribute the app, or
|
||||
attempt to defeat its purchase, and you may not reverse engineer it except where the law
|
||||
expressly allows.
|
||||
</p>
|
||||
|
||||
<h2>Your content is yours</h2>
|
||||
<p>
|
||||
Everything you write in Margin belongs to you. Your book is a file on your own computer. We claim
|
||||
no ownership of it, take no license to it, and never receive a copy. You are responsible for your
|
||||
own work, including keeping your own backups and making sure you have the right to publish what
|
||||
you write.
|
||||
</p>
|
||||
|
||||
<h2>Acceptable use</h2>
|
||||
<p>
|
||||
Use Margin lawfully. Do not use it to create or distribute material that is illegal, or that
|
||||
infringes someone else's rights. What you choose to write and publish is your responsibility, not
|
||||
ours.
|
||||
</p>
|
||||
|
||||
<h2>Third-party services</h2>
|
||||
<p>
|
||||
Margin can connect to services you choose, such as your own Google Drive for backups, and you
|
||||
download and publish through platforms like GitHub and the ebook and print stores. Your use of
|
||||
those services is governed by their own terms and policies, which we do not control.
|
||||
</p>
|
||||
|
||||
<h2>Updates and availability</h2>
|
||||
<p>
|
||||
We work to keep Margin reliable and to improve it over time, but we may change, pause, or
|
||||
discontinue parts of the app or this website. Because Margin runs on your own machine, the copy
|
||||
you have installed keeps working even if a feature that depends on an outside service changes.
|
||||
</p>
|
||||
|
||||
<h2>The app is provided as is</h2>
|
||||
<p>
|
||||
Margin is provided "as is" and "as available," without warranties of any kind, whether express or
|
||||
implied, including fitness for a particular purpose and non-infringement. We do not warrant that
|
||||
the app will be error-free or uninterrupted, or that it will meet every need. You are responsible
|
||||
for backing up your own work.
|
||||
</p>
|
||||
|
||||
<h2>Limitation of liability</h2>
|
||||
<p>
|
||||
To the fullest extent permitted by law, Margin and its maker will not be liable for any indirect,
|
||||
incidental, or consequential damages, or for any loss of data or content, arising from your use
|
||||
of the app. Our total liability for any claim is limited to the amount you paid for Margin.
|
||||
</p>
|
||||
|
||||
<h2>Refunds</h2>
|
||||
<p>
|
||||
If Margin is not working for you, write to us. We would rather make it right or refund your
|
||||
purchase than have you stuck with a tool you cannot use.
|
||||
</p>
|
||||
|
||||
<h2>Changes to these terms</h2>
|
||||
<p>
|
||||
We may update these terms from time to time. When we do, we will change the date at the top of
|
||||
this page. Continuing to use Margin after a change means you accept the updated terms.
|
||||
</p>
|
||||
|
||||
<h2>Governing law</h2>
|
||||
<p>
|
||||
These terms are governed by the laws of the State of New York, United States, without regard to
|
||||
its conflict of laws rules.
|
||||
</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
Questions about these terms? Write to <a href={`mailto:${site.supportEmail}`}>{site.supportEmail}</a>.
|
||||
</p>
|
||||
</Legal>
|
||||
Reference in new issue
Block a user